From 6bad6821df010a0a05165bd87ffef750dbd2337d Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 24 Jul 2026 14:58:13 +0800 Subject: [PATCH 01/20] feat(openviking-controlplane): enterprise library tier + custom request headers - config: add VERSION_CHOICES (developer|enterprise), extra_headers with VIKING_EXTRA_HEADERS env parsing; protect Authorization/Content-Type - client: merge extra headers onto requests; validate version on create - cli: repeatable --header/-H; --version as an enumerated choice; drop the stale 'currently only developer' text - server: document the two tiers + enterprise billing in the tool docstring - docs: README / README_zh / SKILL cover the new header + enterprise usage --- .../README.md | 9 ++++ .../README_zh.md | 8 +++ .../skills/openviking-controlplane/SKILL.md | 9 ++++ .../mcp_server_openviking_controlplane/cli.py | 29 ++++++++-- .../client.py | 8 +++ .../config.py | 53 ++++++++++++++++++- .../server.py | 5 +- 7 files changed, 115 insertions(+), 6 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 1f9543d3..dbdf75d0 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -52,6 +52,12 @@ later without touching the rest. | Control-plane endpoint (base URL) | `VIKING_ENDPOINT` | `--endpoint` / `-e` | `https://api.vikingdb.cn-beijing.volces.com/openviking` | | AgentPlan ApiKey | `AGENTPLAN_API_KEY` | `--api-key` / `-k` | — (required) | | Default project | `OPENVIKING_PROJECT` | `--project` | `default` | +| Extra request headers | `VIKING_EXTRA_HEADERS` | `--header` / `-H` (repeatable) | — | + +`VIKING_EXTRA_HEADERS` is a comma-separated list of `Key: Value` pairs; `--header` +takes one pair and may be repeated (CLI wins over env). Both are merged onto every +request — useful for swim-lane routing, e.g. `-H 'x-tt-env: lujiakun'`. The +`Authorization` and `Content-Type` headers are protected and cannot be overridden. ## CLI usage @@ -71,6 +77,9 @@ uv run ov-cp api-key # model names default, and the model ApiKey falls back to the configured key) uv run ov-cp create --name my_kb +# create an enterprise-tier library (higher capacity, enterprise billing rates) +uv run ov-cp create --name my_kb --version enterprise + # delete (irreversible) uv run ov-cp delete --yes ``` diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index a63af109..07533587 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -48,6 +48,11 @@ Action 在 **path** 里(不走 `?Action=&Version=` query)。请求体是该 | 控制面 endpoint(base URL) | `VIKING_ENDPOINT` | `--endpoint` / `-e` | `https://api.vikingdb.cn-beijing.volces.com/openviking` | | AgentPlan ApiKey | `AGENTPLAN_API_KEY` | `--api-key` / `-k` | —(必填) | | 默认 project | `OPENVIKING_PROJECT` | `--project` | `default` | +| 额外请求头 | `VIKING_EXTRA_HEADERS` | `--header` / `-H`(可重复) | — | + +`VIKING_EXTRA_HEADERS` 是逗号分隔的 `Key: Value` 列表;`--header` 每次带一对、可重复 +(CLI 优先于环境变量)。两者合并后加到每个请求上,常用于泳道路由,例如 +`-H 'x-tt-env: lujiakun'`。`Authorization`、`Content-Type` 为受保护头,不可覆盖。 ## CLI 用法 @@ -67,6 +72,9 @@ uv run ov-cp api-key # 模型名取默认、模型 ApiKey 回落到配置的 key) uv run ov-cp create --name my_kb +# 建企业版库(容量更高,按企业版费率计费) +uv run ov-cp create --name my_kb --version enterprise + # 删库(不可逆) uv run ov-cp delete --yes ``` diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 552929d7..52ef12c2 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -20,6 +20,7 @@ Configure via env vars (CLI flags `-k` / `-e` / `--project` override them): | `AGENTPLAN_API_KEY` | Ark AgentPlan ApiKey (sent as `Authorization: Bearer`) | — (required) | | `VIKING_ENDPOINT` | Control-plane base URL | `https://api.vikingdb.cn-beijing.volces.com/openviking` | | `OPENVIKING_PROJECT` | Default project | `default` | +| `VIKING_EXTRA_HEADERS` | Extra request headers, comma-separated `Key: Value` | — | ```bash export AGENTPLAN_API_KEY=ark-xxxxxxxx @@ -56,12 +57,17 @@ ApiKey falls back to the configured AgentPlan key. ```bash ov-cp create --name my_kb +# enterprise tier (higher capacity, enterprise billing rates): +ov-cp create --name my_kb --version enterprise # other sources need explicit model creds: ov-cp create --name my_kb --source volcengine \ --vlm-api-key-id --vlm-endpoint-id \ --emb-api-key-id --emb-endpoint-id ``` +`--version` is `developer` (default) or `enterprise`; any other value is rejected +locally before the request. + ## Cold-start chain (create → use the library) ```bash @@ -82,3 +88,6 @@ The returned `ApiKey` is the library's **data-plane** key. Use it as - Read-only actions (list/get/usage/delete) are not gated by AgentPlan; create and api-key are. - `get`/`usage`/`api-key`/`delete` take a `ResourceID` (e.g. `ov-xxxxxxxx`). +- Extra headers: pass `-H 'Key: Value'` (repeatable) or set `VIKING_EXTRA_HEADERS` + to a comma-separated `Key: Value` list — e.g. `-H 'x-tt-env: lujiakun'` for + swim-lane routing. `Authorization` / `Content-Type` are protected and ignored. diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 477e6bac..9336bee9 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -1,14 +1,17 @@ import json import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional +import click import typer from mcp_server_openviking_controlplane.client import ControlPlaneClient, ControlPlaneError from mcp_server_openviking_controlplane.config import ( DEFAULT_EMBEDDING_MODEL, DEFAULT_VLM_MODEL, + VERSION_CHOICES, build_config, + parse_extra_headers, ) logging.basicConfig( @@ -81,11 +84,25 @@ def main_callback( project: Optional[str] = typer.Option( None, "--project", help="Default project (overrides OPENVIKING_PROJECT)." ), + header: Optional[List[str]] = typer.Option( + None, "--header", "-H", + help="Extra request header as 'Key: Value'; repeatable. Merged over " + "VIKING_EXTRA_HEADERS (CLI wins). E.g. -H 'x-tt-env: lujiakun' to " + "route into a swim-lane.", + ), ): """Stash a client factory on the context; commands build it on demand.""" def _factory() -> ControlPlaneClient: - config = build_config(endpoint=endpoint, project=project, api_key=api_key) + extra_headers: Dict[str, str] = {} + for item in header or []: + extra_headers.update(parse_extra_headers(item)) + config = build_config( + endpoint=endpoint, + project=project, + api_key=api_key, + extra_headers=extra_headers, + ) return ControlPlaneClient(config) ctx.obj = _factory @@ -139,7 +156,13 @@ def create_cmd( ctx: typer.Context, name: str = typer.Option(..., help="Library name ^[a-zA-Z][a-zA-Z0-9_]*$, <=64."), source: str = typer.Option("agentplan", help="Model source: agentplan | volcengine | codeplan."), - version: str = typer.Option("developer", help="Library version (currently only 'developer')."), + version: str = typer.Option( + "developer", + help="Library tier: developer (default) | enterprise " + "(higher capacity, billed at enterprise rates).", + click_type=click.Choice(VERSION_CHOICES), + metavar="[developer|enterprise]", + ), vlm_model: str = typer.Option(DEFAULT_VLM_MODEL, help="VLM ModelName."), vlm_api_key_id: Optional[str] = typer.Option(None, help="VLM ApiKeyID (exclusive with --vlm-api-key)."), vlm_api_key: Optional[str] = typer.Option(None, help="VLM ApiKey (defaults to --api-key when source=agentplan)."), diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 078f8bcb..19fab695 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -8,6 +8,7 @@ from mcp_server_openviking_controlplane.config import ( DEFAULT_EMBEDDING_MODEL, DEFAULT_VLM_MODEL, + VERSION_CHOICES, ControlPlaneConfig, get_config, ) @@ -54,6 +55,9 @@ def _request(self, action: str, body: Dict[str, Any]) -> Dict[str, Any]: headers = {"Content-Type": "application/json"} headers.update(self.auth.auth_headers("POST", path, {}, body_str)) + # Caller-supplied extra headers (e.g. x-tt-env for swim-lane routing); + # protected keys (Authorization/Content-Type) are already filtered out. + headers.update(self.config.safe_extra_headers()) headers = {k: v for k, v in headers.items() if k.lower() not in _DROP_HEADERS} url = f"{self.config.base_url}{path}" @@ -142,6 +146,10 @@ def create_collection( openviking_version: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: + if version not in VERSION_CHOICES: + raise ValueError( + f"invalid version {version!r}; expected one of {', '.join(VERSION_CHOICES)}" + ) # Multi-credential create format: top-level Source is omitted (each model # carries its source inside Credentials[]). body: Dict[str, Any] = { diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py index 6120cd29..95ad76cf 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py @@ -1,7 +1,7 @@ import logging import os -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from typing import Dict, Optional logger = logging.getLogger(__name__) @@ -23,6 +23,37 @@ DEFAULT_VLM_MODEL = "doubao-seed-2.0-lite" DEFAULT_EMBEDDING_MODEL = "doubao-embedding-vision" +# Library tier (top-level ``Version`` field). "developer" is the free/default +# tier; "enterprise" is the higher-capacity, enterprise-billed tier. +VERSION_CHOICES = ("developer", "enterprise") + +# Header names that extra_headers must never override: auth and content type are +# owned by the client and a stray value would break the request. +_PROTECTED_HEADERS = {"authorization", "content-type"} + + +def parse_extra_headers(raw: Optional[str]) -> Dict[str, str]: + """Parse a comma-separated ``Key: Value`` header string (e.g. from + ``VIKING_EXTRA_HEADERS``) into a dict. Tolerates spaces after the colon and + around commas. Blank entries are skipped; the value may itself contain + colons (only the first splits key from value).""" + result: Dict[str, str] = {} + if not raw: + return result + for item in raw.split(","): + item = item.strip() + if not item: + continue + if ":" not in item: + logger.warning("ignoring malformed extra header (no colon): %r", item) + continue + key, value = item.split(":", 1) + key = key.strip() + if not key: + continue + result[key] = value.strip() + return result + @dataclass class ControlPlaneConfig: @@ -31,6 +62,7 @@ class ControlPlaneConfig: api_key: str endpoint: str = DEFAULT_ENDPOINT project: str = DEFAULT_PROJECT + extra_headers: Dict[str, str] = field(default_factory=dict) @property def base_url(self) -> str: @@ -39,11 +71,23 @@ def base_url(self) -> str: def action_path(self, action: str) -> str: return f"{ACTION_PATH_PREFIX}/{action}" + def safe_extra_headers(self) -> Dict[str, str]: + """extra_headers with protected (auth/content-type) keys dropped, so + callers can merge them onto request headers without clobbering auth.""" + safe: Dict[str, str] = {} + for key, value in self.extra_headers.items(): + if key.lower() in _PROTECTED_HEADERS: + logger.warning("ignoring protected extra header: %s", key) + continue + safe[key] = value + return safe + def build_config( endpoint: Optional[str] = None, project: Optional[str] = None, api_key: Optional[str] = None, + extra_headers: Optional[Dict[str, str]] = None, ) -> ControlPlaneConfig: """Build a config from explicit args first, then environment fallbacks, then package defaults.""" @@ -53,10 +97,15 @@ def build_config( "missing AgentPlan API key: set --api-key or the AGENTPLAN_API_KEY env var " "(the Ark AgentPlan ApiKey sent as 'Authorization: Bearer ')" ) + # env baseline, then merge explicit headers on top (explicit wins). + headers = parse_extra_headers(os.environ.get("VIKING_EXTRA_HEADERS")) + if extra_headers: + headers.update(extra_headers) return ControlPlaneConfig( api_key=resolved_key, endpoint=endpoint or os.environ.get("VIKING_ENDPOINT", DEFAULT_ENDPOINT), project=project or os.environ.get("OPENVIKING_PROJECT", DEFAULT_PROJECT), + extra_headers=headers, ) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 407e9feb..d5b24f88 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -130,7 +130,10 @@ def create_collection( AgentPlan key. ApiKeyID and ApiKey are mutually exclusive. embedding: optional embedding model config, same shape/defaults as vlm. source: model source — "agentplan" (default), "volcengine", or "codeplan". - version: library version, currently only "developer". + version: library tier — "developer" (default) or "enterprise". The + enterprise tier has higher capacity and is billed at enterprise + rates (25 AFP baseline / 200k files, then tiered per 100k files + beyond). Any other value is rejected immediately with an error. project: project name; defaults to the configured project. description: optional, length <= 65535. openviking_version: optional image version. From b382ab95eb4929d997444c8f41939585d8063502 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 24 Jul 2026 15:47:17 +0800 Subject: [PATCH 02/20] feat(openviking-controlplane): user management + collection update Add the remaining 5 data-plane control-plane Actions so MCP/CLI cover all 11 documented actions: - UpdateOpenVikingCollection (update_collection / ov-cp update) - ListOpenVikingCollectionUser (list_collection_users / ov-cp user list) - RegisterOpenVikingUser (register_collection_user / ov-cp user register) - UpdateOpenVikingUser (update_collection_user / ov-cp user update) - DeleteOpenVikingUser (delete_collection_user / ov-cp user delete) User actions require the AgentPlan key to be associated with the target library; user list returns a masked ApiKey (plaintext via api-key). UpdateOpenVikingCollection re-validates model credentials, so VLM/Embedding blocks are always sent (built like create, defaulting to the AgentPlan key). Contract verified E2E on the lujiakun swim-lane against an enterprise-tier library: user register/list/update/delete round-trip and collection update. --- .../README.md | 21 ++- .../README_zh.md | 19 ++- .../skills/openviking-controlplane/SKILL.md | 16 ++- .../mcp_server_openviking_controlplane/cli.py | 92 +++++++++++++ .../client.py | 79 +++++++++++ .../server.py | 125 ++++++++++++++++++ 6 files changed, 348 insertions(+), 4 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index dbdf75d0..0ebe7ea5 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -4,16 +4,26 @@ MCP server **and** CLI for the OpenViking control plane (topapi) — manage OV libraries (`Collection`). Both front-ends share one core (`client.py`), so a tool added once is available from MCP and the CLI alike. -Covers the 6 core control-plane Actions: +Covers the 11 control-plane Actions: | Action | MCP tool | CLI command | |---|---|---| | `ListOpenVikingCollections` | `list_collections` | `ov-cp list` | | `CreateOpenVikingCollection` | `create_collection` ⚠️ | `ov-cp create` | | `GetOpenVikingCollection` | `get_collection` | `ov-cp get ` | +| `UpdateOpenVikingCollection` | `update_collection` | `ov-cp update ` | | `DeleteOpenVikingCollection` | `delete_collection` ⚠️ | `ov-cp delete ` | | `GetOpenVikingUsage` | `get_usage` | `ov-cp usage ` | | `GetOpenVikingCollectionUserAccess` | `get_collection_api_key` | `ov-cp api-key ` | +| `ListOpenVikingCollectionUser` | `list_collection_users` | `ov-cp user list ` | +| `RegisterOpenVikingUser` | `register_collection_user` | `ov-cp user register ` | +| `UpdateOpenVikingUser` | `update_collection_user` | `ov-cp user update ` | +| `DeleteOpenVikingUser` | `delete_collection_user` ⚠️ | `ov-cp user delete ` | + +The `user *` actions manage the multiple users of an enterprise-tier library; they +require the AgentPlan key to be **associated with the target library**. A user's +`ApiKey` from `user list` is **masked** — fetch a plaintext data-plane key via +`api-key`. ## Endpoint @@ -80,6 +90,15 @@ uv run ov-cp create --name my_kb # create an enterprise-tier library (higher capacity, enterprise billing rates) uv run ov-cp create --name my_kb --version enterprise +# update mutable fields (only the flags you pass change) +uv run ov-cp update --description "new description" + +# manage users of an enterprise-tier library (key must be associated with it) +uv run ov-cp user list +uv run ov-cp user register xiaohong --role user +uv run ov-cp user update xiaohong --role admin +uv run ov-cp user delete xiaohong --yes + # delete (irreversible) uv run ov-cp delete --yes ``` diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 07533587..4524d7e2 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -4,16 +4,24 @@ OpenViking 控制面(topapi)的 MCP Server **与** CLI —— 用于管理 O (`Collection`)。两个前端共用同一套核心(`client.py`),新增一个能力即可同时被 MCP 和 CLI 使用。 -覆盖 6 个核心控制面 Action: +覆盖 11 个控制面 Action: | Action | MCP tool | CLI 命令 | |---|---|---| | `ListOpenVikingCollections` | `list_collections` | `ov-cp list` | | `CreateOpenVikingCollection` | `create_collection` ⚠️ | `ov-cp create` | | `GetOpenVikingCollection` | `get_collection` | `ov-cp get ` | +| `UpdateOpenVikingCollection` | `update_collection` | `ov-cp update ` | | `DeleteOpenVikingCollection` | `delete_collection` ⚠️ | `ov-cp delete ` | | `GetOpenVikingUsage` | `get_usage` | `ov-cp usage ` | | `GetOpenVikingCollectionUserAccess` | `get_collection_api_key` | `ov-cp api-key ` | +| `ListOpenVikingCollectionUser` | `list_collection_users` | `ov-cp user list ` | +| `RegisterOpenVikingUser` | `register_collection_user` | `ov-cp user register ` | +| `UpdateOpenVikingUser` | `update_collection_user` | `ov-cp user update ` | +| `DeleteOpenVikingUser` | `delete_collection_user` ⚠️ | `ov-cp user delete ` | + +`user *` 系列管理企业版库的多用户,要求 AgentPlan key **与目标库已关联**。`user list` +返回的用户 `ApiKey` 是**掩码**,取明文数据面 key 走 `api-key`。 ## 端点 @@ -75,6 +83,15 @@ uv run ov-cp create --name my_kb # 建企业版库(容量更高,按企业版费率计费) uv run ov-cp create --name my_kb --version enterprise +# 更新库可变字段(只改传入的字段) +uv run ov-cp update --description "新描述" + +# 管理企业版库的用户(key 需与该库已关联) +uv run ov-cp user list +uv run ov-cp user register xiaohong --role user +uv run ov-cp user update xiaohong --role admin +uv run ov-cp user delete xiaohong --yes + # 删库(不可逆) uv run ov-cp delete --yes ``` diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 52ef12c2..f5251509 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -1,6 +1,6 @@ --- name: openviking-controlplane -description: Manage OpenViking collections (OV libraries) from the command line with `ov-cp` — list / create / get / usage / get the data-plane API key / delete. Use when the user wants to provision or inspect an OpenViking library, fetch a library's data-plane API key, do the create→get-key cold-start, or otherwise drive the OpenViking control plane (topapi). Authenticates with an Ark AgentPlan ApiKey. +description: Manage OpenViking collections (OV libraries) from the command line with `ov-cp` — list / create / get / update / usage / get the data-plane API key / delete, plus managing the users of an enterprise-tier library (list / register / update / delete). Use when the user wants to provision or inspect an OpenViking library, fetch a library's data-plane API key, do the create→get-key cold-start, manage a library's users, or otherwise drive the OpenViking control plane (topapi). Authenticates with an Ark AgentPlan ApiKey. --- # OpenViking Control Plane (`ov-cp`) @@ -40,7 +40,14 @@ ov-cp get # collection info (Status, models, version, ... ov-cp usage # file counts / estimated cost ov-cp api-key # plaintext data-plane key {UserID, Role, ApiKey} ov-cp create --name my_kb # create a collection (see below) +ov-cp update --description "..." # update mutable fields ov-cp delete --yes # delete (irreversible; uninstalls the Helm release) + +# users of an enterprise-tier library (key must be associated with the library): +ov-cp user list # users (ApiKey is masked) +ov-cp user register xiaohong --role user # add a user (UserID + role) +ov-cp user update xiaohong --role admin +ov-cp user delete xiaohong --yes # revoke a user's credential ``` Output is JSON. Errors print `Error [Code]: Message` to stderr with exit code 1. @@ -87,7 +94,12 @@ The returned `ApiKey` is the library's **data-plane** key. Use it as - Only `Authorization: Bearer` is accepted (no `X-API-Key`). - Read-only actions (list/get/usage/delete) are not gated by AgentPlan; create and api-key are. -- `get`/`usage`/`api-key`/`delete` take a `ResourceID` (e.g. `ov-xxxxxxxx`). +- `get`/`usage`/`api-key`/`delete`/`update` and all `user *` take a `ResourceID` + (e.g. `ov-xxxxxxxx`). +- `user *` manages the multiple users of an **enterprise-tier** library and needs the + AgentPlan key to be **associated with that library** (else the backend rejects it). + `user list` returns each user's **masked** ApiKey; for a plaintext data-plane key + use `api-key`. - Extra headers: pass `-H 'Key: Value'` (repeatable) or set `VIKING_EXTRA_HEADERS` to a comma-separated `Key: Value` list — e.g. `-H 'x-tt-env: lujiakun'` for swim-lane routing. `Authorization` / `Content-Type` are protected and ignored. diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 9336bee9..3d2fb3d8 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -200,6 +200,98 @@ def create_cmd( raise _fail(e) +@app.command("update") +def update_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + description: Optional[str] = typer.Option(None, help="New description, <=65535 chars."), + openviking_version: Optional[str] = typer.Option(None, help="New image version."), +): + """Update mutable fields of a collection (only passed fields change).""" + client = _client(ctx) + try: + _print( + client.update_collection( + resource_id, + description=description, + openviking_version=openviking_version, + ) + ) + except Exception as e: + raise _fail(e) + + +user_app = typer.Typer( + help="Manage users under a collection (enterprise-tier libraries). " + "All actions require the AgentPlan key to be associated with the library.", + no_args_is_help=True, +) +app.add_typer(user_app, name="user") + + +@user_app.command("list") +def user_list_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), +): + """List users under a collection (ApiKey is masked; use `api-key` for plaintext).""" + client = _client(ctx) + try: + _print(client.list_collection_users(resource_id)) + except Exception as e: + raise _fail(e) + + +@user_app.command("register") +def user_register_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + user_id: str = typer.Argument(..., help="UserID for the new user (unique in library)."), + role: Optional[str] = typer.Option(None, help="Role, e.g. admin | user."), +): + """Register a new user under a collection.""" + client = _client(ctx) + try: + _print(client.register_user(resource_id, user_id, role=role)) + except Exception as e: + raise _fail(e) + + +@user_app.command("update") +def user_update_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + user_id: str = typer.Argument(..., help="Target UserID."), + role: Optional[str] = typer.Option(None, help="New role, e.g. admin | user."), +): + """Update a user under a collection (only passed fields change).""" + client = _client(ctx) + try: + _print(client.update_user(resource_id, user_id, role=role)) + except Exception as e: + raise _fail(e) + + +@user_app.command("delete") +def user_delete_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + user_id: str = typer.Argument(..., help="Target UserID."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +): + """Delete a user from a collection (revokes its credential; irreversible).""" + client = _client(ctx) + if not yes: + typer.confirm( + f"Delete user {user_id} from collection {resource_id} (revokes its credential)?", + abort=True, + ) + try: + _print(client.delete_user(resource_id, user_id)) + except Exception as e: + raise _fail(e) + + @app.command("delete") def delete_cmd( ctx: typer.Context, diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 19fab695..853ff4a8 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -172,6 +172,37 @@ def create_collection( def get_collection(self, resource_id: str) -> Dict[str, Any]: return self._request("GetOpenVikingCollection", {"ResourceID": resource_id}) + def update_collection( + self, + resource_id: str, + description: Optional[str] = None, + source: str = "agentplan", + vlm: Optional[Dict[str, Any]] = None, + embedding: Optional[Dict[str, Any]] = None, + openviking_version: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Update a collection's mutable fields (e.g. Description). + + NOTE: the backend re-validates model credentials on every update, so VLM and + Embedding blocks are always sent (built like ``create_collection`` — for + ``source == "agentplan"`` the model credential falls back to the configured + AgentPlan key). Passing an empty/whitespace Description is a server-side no-op + (the field is only overwritten by a non-empty value). ``extra`` is merged + verbatim for forward-compatibility (e.g. an eventual ``PaymentConfig``).""" + body: Dict[str, Any] = { + "ResourceID": resource_id, + "VLM": self._model_block(vlm, source, DEFAULT_VLM_MODEL), + "Embedding": self._model_block(embedding, source, DEFAULT_EMBEDDING_MODEL), + } + if description is not None: + body["Description"] = description + if openviking_version is not None: + body["OpenvikingVersion"] = openviking_version + if extra: + body.update(extra) + return self._request("UpdateOpenVikingCollection", body) + def delete_collection(self, resource_id: str) -> Dict[str, Any]: return self._request("DeleteOpenVikingCollection", {"ResourceID": resource_id}) @@ -191,6 +222,54 @@ def get_user_access(self, resource_id: str) -> Dict[str, Any]: "GetOpenVikingCollectionUserAccess", {"ResourceID": resource_id} ) + # --- User management (enterprise-tier libraries: multi-user) ------------- + # These require the AgentPlan key to be associated with the target library; + # operating on an unassociated library is rejected server-side. The ApiKey in + # a List response is MASKED — fetch the plaintext key via get_user_access. + + def list_collection_users(self, resource_id: str) -> Dict[str, Any]: + # ListOpenVikingCollectionUser: users under the library (ApiKey masked). + return self._request( + "ListOpenVikingCollectionUser", {"ResourceID": resource_id} + ) + + def register_user( + self, + resource_id: str, + user_id: str, + role: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + # RegisterOpenVikingUser: create a new user under the library. UserID is + # required; Role is e.g. "admin" / "user". + body: Dict[str, Any] = {"ResourceID": resource_id, "UserID": user_id} + if role is not None: + body["Role"] = role + if extra: + body.update(extra) + return self._request("RegisterOpenVikingUser", body) + + def update_user( + self, + resource_id: str, + user_id: str, + role: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + # UpdateOpenVikingUser: update a user's mutable fields (e.g. Role). + body: Dict[str, Any] = {"ResourceID": resource_id, "UserID": user_id} + if role is not None: + body["Role"] = role + if extra: + body.update(extra) + return self._request("UpdateOpenVikingUser", body) + + def delete_user(self, resource_id: str, user_id: str) -> Dict[str, Any]: + # DeleteOpenVikingUser: remove a user from the library. + return self._request( + "DeleteOpenVikingUser", {"ResourceID": resource_id, "UserID": user_id} + ) + _client: Optional[ControlPlaneClient] = None diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index d5b24f88..8cee7bb7 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -157,6 +157,131 @@ def create_collection( return _err(e) +@mcp.tool() +def update_collection( + resource_id: str, + description: Optional[str] = None, + openviking_version: Optional[str] = None, +) -> Dict[str, Any]: + """Update mutable fields of an OpenViking collection (UpdateOpenVikingCollection). + + Requires the AgentPlan key to be associated with the target library. CONFIRM WITH + THE USER before calling — this mutates a live library. The backend re-validates + model credentials on update, so VLM/Embedding are sent automatically using the + configured AgentPlan key. NOTE: an empty/whitespace description is a server-side + no-op — the description can only be overwritten with a non-empty value. + + Args: + resource_id: target library ResourceID. + description: new description, length <= 65535 (non-empty to take effect). + openviking_version: new image version. + + Returns: + {"Success": true} + """ + try: + return get_client().update_collection( + resource_id, + description=description, + openviking_version=openviking_version, + ) + except Exception as e: + logger.error(f"update_collection failed: {e}") + return _err(e) + + +@mcp.tool() +def list_collection_users(resource_id: str) -> Dict[str, Any]: + """List the users registered under one OpenViking collection. + + Backed by ListOpenVikingCollectionUser. Requires the AgentPlan key to be + associated with the target library. NOTE: the ApiKey in each entry is MASKED; + to get a plaintext data-plane key use get_collection_api_key. + + Args: + resource_id: target library ResourceID. + + Returns: + {"UserList": [ {"UserID", "Role", "ApiKey" (masked)} ], "Total": N} + """ + try: + return get_client().list_collection_users(resource_id) + except Exception as e: + logger.error(f"list_collection_users failed: {e}") + return _err(e) + + +@mcp.tool() +def register_collection_user( + resource_id: str, user_id: str, role: Optional[str] = None +) -> Dict[str, Any]: + """Register a NEW user under an OpenViking collection (RegisterOpenVikingUser). + + Requires the AgentPlan key to be associated with the target library. CONFIRM + WITH THE USER before calling — this creates a new credentialed user. + + Args: + resource_id: target library ResourceID. + user_id: the UserID for the new user (unique within the library). + role: optional role, e.g. "admin" or "user". + + Returns: + {"Success": true} + """ + try: + return get_client().register_user(resource_id, user_id, role=role) + except Exception as e: + logger.error(f"register_collection_user failed: {e}") + return _err(e) + + +@mcp.tool() +def update_collection_user( + resource_id: str, + user_id: str, + role: Optional[str] = None, +) -> Dict[str, Any]: + """Update a user under an OpenViking collection (UpdateOpenVikingUser). + + Only the fields you pass (non-None) are changed. Requires the AgentPlan key to be + associated with the target library. CONFIRM WITH THE USER before calling. + + Args: + resource_id: target library ResourceID. + user_id: the UserID to update. + role: optional new role, e.g. "admin" or "user". + + Returns: + {"Success": true} + """ + try: + return get_client().update_user(resource_id, user_id, role=role) + except Exception as e: + logger.error(f"update_collection_user failed: {e}") + return _err(e) + + +@mcp.tool() +def delete_collection_user(resource_id: str, user_id: str) -> Dict[str, Any]: + """⚠️ Delete a user from an OpenViking collection (DeleteOpenVikingUser). + + CONFIRM WITH THE USER before calling. This revokes the user's credential and + cannot be undone. Requires the AgentPlan key to be associated with the library. + + Args: + resource_id: target library ResourceID. + user_id: the UserID to delete. + + Returns: + {"Success": true} + """ + try: + return get_client().delete_user(resource_id, user_id) + except Exception as e: + logger.error(f"delete_collection_user failed: {e}") + return _err(e) + + @mcp.tool() def delete_collection(resource_id: str) -> Dict[str, Any]: """⚠️ IRREVERSIBLY deletes an OpenViking collection (uninstalls its Helm release). From 5a22816c148b58a7e7ed6102a57509b20fa0fcda Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Thu, 30 Jul 2026 16:08:24 +0800 Subject: [PATCH 03/20] feat(openviking-controlplane): billing config on create/update Expose PaymentConfig as first-class billing arguments on create and update, in both the CLI (ov-cp) and the MCP tools: - One flat user-facing enum --pay-type: agentplan_personal | agentplan_enterprise | volc_pay (split into wire PayType + AgentPlanConfig.BusinessScenarios by build_payment_config). The personal/enterprise choice is always explicit, never inferred from the key or the seat. - --seat-id: required with agentplan_enterprise, forbidden otherwise. Entered manually (no lookup API); the server does not verify the seat exists. - Omitting --pay-type on create leaves PaymentConfig unset and the server defaults to volc_pay (real-money Volcano pay-as-you-go); the CLI prints a warning to stderr. - update with pay flags is the billing-switch path (volc_pay <-> AgentPlan deduction, seat re-bind); omitting them leaves billing untouched. empty_pay is deliberately not offered. - All valid/invalid flag combinations fail fast locally before any request; verified live on stg (create with PaymentConfig, personal with empty SeatId, three-way billing switches). --- .../README.md | 14 +++- .../README_zh.md | 11 ++- .../skills/openviking-controlplane/SKILL.md | 30 +++++++- .../mcp_server_openviking_controlplane/cli.py | 58 ++++++++++++++- .../client.py | 73 ++++++++++++++++++- .../config.py | 14 ++++ .../server.py | 51 +++++++++++-- 7 files changed, 236 insertions(+), 15 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 0ebe7ea5..1a6b97ed 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -90,8 +90,20 @@ uv run ov-cp create --name my_kb # create an enterprise-tier library (higher capacity, enterprise billing rates) uv run ov-cp create --name my_kb --version enterprise -# update mutable fields (only the flags you pass change) +# billing (--pay-type): who pays for the library — orthogonal to --version, +# which only sets the rate. ⚠️ Omitted => the server defaults to volc_pay +# (Volcano pay-as-you-go, REAL MONEY), not AgentPlan AFP deduction. +uv run ov-cp create --name my_kb --pay-type agentplan_personal +uv run ov-cp create --name my_kb --version enterprise \ + --pay-type agentplan_enterprise --seat-id seat-2026xxxx +# --seat-id: the enterprise seat that pays. Copy it manually from the Ark +# console seat-management page — the server does NOT verify the seat exists; +# a typo only surfaces at the next hourly deduction, disabling the library. + +# update mutable fields (only the flags you pass change); +# also switches billing (volc_pay <-> AgentPlan, or re-bind a seat) uv run ov-cp update --description "new description" +uv run ov-cp update --pay-type volc_pay # manage users of an enterprise-tier library (key must be associated with it) uv run ov-cp user list diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 4524d7e2..e1c6dc57 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -83,8 +83,17 @@ uv run ov-cp create --name my_kb # 建企业版库(容量更高,按企业版费率计费) uv run ov-cp create --name my_kb --version enterprise -# 更新库可变字段(只改传入的字段) +# 计费方式(--pay-type):库由谁付钱——与 --version 正交(--version 只决定费率)。 +# ⚠️ 不传时服务端默认 volc_pay(火山官网按量,扣真金白银),不走 AgentPlan AFP 抵扣。 +uv run ov-cp create --name my_kb --pay-type agentplan_personal +uv run ov-cp create --name my_kb --version enterprise \ + --pay-type agentplan_enterprise --seat-id seat-2026xxxx +# --seat-id:付费的企业版席位,需自行从方舟控制台「席位管理」页复制—— +# 服务端不校验席位是否存在,填错要到下一个小时抵扣时才暴露(届时库被停用)。 + +# 更新库可变字段(只改传入的字段);也用于切换计费方式 / 换绑席位 uv run ov-cp update --description "新描述" +uv run ov-cp update --pay-type volc_pay # 管理企业版库的用户(key 需与该库已关联) uv run ov-cp user list diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index f5251509..818af515 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -1,6 +1,6 @@ --- name: openviking-controlplane -description: Manage OpenViking collections (OV libraries) from the command line with `ov-cp` — list / create / get / update / usage / get the data-plane API key / delete, plus managing the users of an enterprise-tier library (list / register / update / delete). Use when the user wants to provision or inspect an OpenViking library, fetch a library's data-plane API key, do the create→get-key cold-start, manage a library's users, or otherwise drive the OpenViking control plane (topapi). Authenticates with an Ark AgentPlan ApiKey. +description: Manage OpenViking collections (OV libraries) from the command line with `ov-cp` — list / create / get / update / usage / get the data-plane API key / delete, plus managing the users of an enterprise-tier library (list / register / update / delete) and configuring how a library is billed (AgentPlan AFP deduction vs Volcano pay-as-you-go, `--pay-type` / `--seat-id`). Use when the user wants to provision or inspect an OpenViking library, fetch a library's data-plane API key, do the create→get-key cold-start, manage a library's users, set or switch a library's billing, or otherwise drive the OpenViking control plane (topapi). Authenticates with an Ark AgentPlan ApiKey. --- # OpenViking Control Plane (`ov-cp`) @@ -40,7 +40,7 @@ ov-cp get # collection info (Status, models, version, ... ov-cp usage # file counts / estimated cost ov-cp api-key # plaintext data-plane key {UserID, Role, ApiKey} ov-cp create --name my_kb # create a collection (see below) -ov-cp update --description "..." # update mutable fields +ov-cp update --description "..." # update fields / switch billing ov-cp delete --yes # delete (irreversible; uninstalls the Helm release) # users of an enterprise-tier library (key must be associated with the library): @@ -75,6 +75,32 @@ ov-cp create --name my_kb --source volcengine \ `--version` is `developer` (default) or `enterprise`; any other value is rejected locally before the request. +## Billing (`--pay-type` / `--seat-id`) + +`--version` and billing are **orthogonal**: the tier sets the hourly RATE +(developer 5 AFP baseline, enterprise 25 AFP baseline), `--pay-type` sets WHO +PAYS. Both `create` and `update` take the same two flags (`update` is how you +switch billing later, or re-bind after a seat was unbound). + +```bash +ov-cp create --name my_kb --pay-type agentplan_personal # personal AFP pays +ov-cp create --name my_kb --version enterprise \ + --pay-type agentplan_enterprise --seat-id seat-2026xxxx # that seat's AFP pays +ov-cp create --name my_kb --pay-type volc_pay # explicit website PAYG +ov-cp update --pay-type volc_pay # switch billing later +``` + +- ⚠️ **Omitting `--pay-type` on create => the server defaults to `volc_pay`**: + Volcano pay-as-you-go, billed in REAL MONEY, not AgentPlan AFP. The CLI prints + a warning; always confirm with the user which billing they want. +- The personal/enterprise choice is always explicit — never guess it from the key. +- `--seat-id` is required with `agentplan_enterprise` and forbidden otherwise. + The user must copy it manually from the Ark console seat-management page + (no lookup API). The server does NOT verify the seat exists — a typo only + surfaces at the next hourly deduction, which then disables the library. +- `empty_pay` (unbound) exists server-side but is not offered: such a library is + unusable and auto-cleaned after 30 days. + ## Cold-start chain (create → use the library) ```bash diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 3d2fb3d8..5a18ddc0 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -9,6 +9,7 @@ from mcp_server_openviking_controlplane.config import ( DEFAULT_EMBEDDING_MODEL, DEFAULT_VLM_MODEL, + PAY_TYPE_CHOICES, VERSION_CHOICES, build_config, parse_extra_headers, @@ -174,13 +175,44 @@ def create_cmd( project: Optional[str] = typer.Option(None, help="Project name (defaults to configured)."), description: Optional[str] = typer.Option(None, help="Description, <=65535 chars."), openviking_version: Optional[str] = typer.Option(None, help="Image version (optional)."), + pay_type: Optional[str] = typer.Option( + None, "--pay-type", + help="Billing: agentplan_personal (personal AgentPlan AFP deduction) | " + "agentplan_enterprise (an enterprise seat's AFP pays; requires " + "--seat-id) | volc_pay (Volcano pay-as-you-go). ⚠️ If omitted, the " + "server defaults to volc_pay — REAL MONEY billed to the Volcano " + "account, not AgentPlan AFP.", + click_type=click.Choice(PAY_TYPE_CHOICES), + metavar="[agentplan_personal|agentplan_enterprise|volc_pay]", + ), + seat_id: Optional[str] = typer.Option( + None, "--seat-id", + help="AgentPlan enterprise seat that pays (e.g. seat-2026...); required " + "with --pay-type agentplan_enterprise. Copy it manually from the Ark " + "console seat-management page — the server does NOT check the seat " + "exists; a typo only surfaces at the next hourly deduction, which " + "then disables the library.", + ), ): """Create a new collection (consumes paid quota; max 20 per account). For source=agentplan you can pass just --name: the model names default to the AgentPlan models and the model ApiKey falls back to --api-key / AGENTPLAN_API_KEY. + + ⚠️ Billing: without --pay-type the server defaults the library to volc_pay + (Volcano pay-as-you-go, real money). For AgentPlan AFP deduction pass + --pay-type agentplan_personal, or --pay-type agentplan_enterprise --seat-id + seat-xxx. """ client = _client(ctx) + if not (pay_type or seat_id): + typer.echo( + "warning: no --pay-type — the server will default this library to " + "volc_pay (Volcano pay-as-you-go, REAL MONEY, not AgentPlan AFP). " + "Pass --pay-type agentplan_personal or agentplan_enterprise for AFP " + "deduction.", + err=True, + ) vlm = _model_cfg(vlm_model, vlm_api_key_id, vlm_api_key, vlm_endpoint_id) embedding = _model_cfg(emb_model, emb_api_key_id, emb_api_key, emb_endpoint_id) try: @@ -194,6 +226,8 @@ def create_cmd( project=project, description=description, openviking_version=openviking_version, + pay_type=pay_type, + seat_id=seat_id, ) ) except Exception as e: @@ -206,8 +240,28 @@ def update_cmd( resource_id: str = typer.Argument(..., help="Target library ResourceID."), description: Optional[str] = typer.Option(None, help="New description, <=65535 chars."), openviking_version: Optional[str] = typer.Option(None, help="New image version."), + pay_type: Optional[str] = typer.Option( + None, "--pay-type", + help="Switch billing: agentplan_personal (personal AgentPlan AFP) | " + "agentplan_enterprise (an enterprise seat's AFP; requires --seat-id) " + "| volc_pay (Volcano pay-as-you-go, real money). Omit to leave " + "billing untouched.", + click_type=click.Choice(PAY_TYPE_CHOICES), + metavar="[agentplan_personal|agentplan_enterprise|volc_pay]", + ), + seat_id: Optional[str] = typer.Option( + None, "--seat-id", + help="AgentPlan enterprise seat that pays; required with --pay-type " + "agentplan_enterprise (also how to re-bind after a seat was " + "unbound). The server does NOT check the seat exists.", + ), ): - """Update mutable fields of a collection (only passed fields change).""" + """Update mutable fields of a collection (only passed fields change). + + Also switches billing: e.g. `update --pay-type agentplan_enterprise + --seat-id seat-xxx` moves the library to AFP deduction from that seat; + `--pay-type volc_pay` moves it back to Volcano pay-as-you-go. + """ client = _client(ctx) try: _print( @@ -215,6 +269,8 @@ def update_cmd( resource_id, description=description, openviking_version=openviking_version, + pay_type=pay_type, + seat_id=seat_id, ) ) except Exception as e: diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 853ff4a8..b07f884b 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -8,6 +8,7 @@ from mcp_server_openviking_controlplane.config import ( DEFAULT_EMBEDDING_MODEL, DEFAULT_VLM_MODEL, + PAY_TYPE_MAP, VERSION_CHOICES, ControlPlaneConfig, get_config, @@ -20,6 +21,59 @@ _DROP_HEADERS = {"content-length", "connection", "accept-encoding"} +def build_payment_config( + pay_type: Optional[str] = None, + seat_id: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Validate billing arguments and build the ``PaymentConfig`` request block. + + ``pay_type`` is the flat user-facing enum (``agentplan_personal`` / + ``agentplan_enterprise`` / ``volc_pay``); the wire split into PayType + + BusinessScenarios happens here. Returns None when nothing was given — the + server then defaults the library to ``volc_pay``: Volcano pay-as-you-go, + billed in real money to the Volcano account, NOT AgentPlan AFP. The server + only checks a SeatId is non-empty, not that it exists: a typo surfaces at + the next hourly deduction, after which the library is disabled. + """ + if not (pay_type or seat_id): + return None + if pay_type is None: # only seat_id was given + raise ValueError( + "seat_id alone is ambiguous: also pass pay_type='agentplan_enterprise'" + ) + if pay_type == "agentplan_pay": + raise ValueError( + "'agentplan_pay' is ambiguous here: use 'agentplan_personal' or " + "'agentplan_enterprise' (the choice is always explicit)" + ) + if pay_type not in PAY_TYPE_MAP: + raise ValueError( + f"invalid pay_type {pay_type!r}; expected one of {', '.join(PAY_TYPE_MAP)} " + "(empty_pay is not offered: an unbound library is unusable and " + "auto-cleaned after 30 days)" + ) + + wire_type, scenario = PAY_TYPE_MAP[pay_type] + if wire_type == "volc_pay": + if seat_id: + raise ValueError("seat_id only applies to pay_type='agentplan_enterprise'") + return {"PayType": "volc_pay"} + if scenario == "agent_plan_enterprise" and not seat_id: + raise ValueError( + "pay_type='agentplan_enterprise' requires seat_id — the seat that pays; " + "copy it from the Ark console seat-management page" + ) + if scenario == "agent_plan_personal" and seat_id: + raise ValueError( + "pay_type='agentplan_personal' must not carry a seat_id " + "(a personal plan has no seat)" + ) + return { + "PayType": wire_type, + "AgentPlanConfig": {"BusinessScenarios": scenario, "SeatId": seat_id or ""}, + } + + class ControlPlaneError(RuntimeError): """Raised when the control plane returns an Error envelope or a non-200 status.""" @@ -144,12 +198,15 @@ def create_collection( project: Optional[str] = None, description: Optional[str] = None, openviking_version: Optional[str] = None, + pay_type: Optional[str] = None, + seat_id: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: if version not in VERSION_CHOICES: raise ValueError( f"invalid version {version!r}; expected one of {', '.join(VERSION_CHOICES)}" ) + payment = build_payment_config(pay_type, seat_id) # Multi-credential create format: top-level Source is omitted (each model # carries its source inside Credentials[]). body: Dict[str, Any] = { @@ -158,6 +215,8 @@ def create_collection( "VLM": self._model_block(vlm, source, DEFAULT_VLM_MODEL), "Embedding": self._model_block(embedding, source, DEFAULT_EMBEDDING_MODEL), } + if payment is not None: + body["PaymentConfig"] = payment proj = project if project is not None else self.config.project if proj: body["Project"] = proj @@ -180,21 +239,31 @@ def update_collection( vlm: Optional[Dict[str, Any]] = None, embedding: Optional[Dict[str, Any]] = None, openviking_version: Optional[str] = None, + pay_type: Optional[str] = None, + seat_id: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: - """Update a collection's mutable fields (e.g. Description). + """Update a collection's mutable fields (e.g. Description, PaymentConfig). + + This is also the way to CHANGE how a library is billed (volc_pay ↔ + AgentPlan deduction, or re-bind a seat after it was unbound): pass + pay_type / seat_id, validated by ``build_payment_config``. Omitting + both leaves the current billing untouched. NOTE: the backend re-validates model credentials on every update, so VLM and Embedding blocks are always sent (built like ``create_collection`` — for ``source == "agentplan"`` the model credential falls back to the configured AgentPlan key). Passing an empty/whitespace Description is a server-side no-op (the field is only overwritten by a non-empty value). ``extra`` is merged - verbatim for forward-compatibility (e.g. an eventual ``PaymentConfig``).""" + verbatim for forward-compatibility.""" + payment = build_payment_config(pay_type, seat_id) body: Dict[str, Any] = { "ResourceID": resource_id, "VLM": self._model_block(vlm, source, DEFAULT_VLM_MODEL), "Embedding": self._model_block(embedding, source, DEFAULT_EMBEDDING_MODEL), } + if payment is not None: + body["PaymentConfig"] = payment if description is not None: body["Description"] = description if openviking_version is not None: diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py index 95ad76cf..8ff6ec94 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py @@ -27,6 +27,20 @@ # tier; "enterprise" is the higher-capacity, enterprise-billed tier. VERSION_CHOICES = ("developer", "enterprise") +# Billing (``PaymentConfig``): how a library is paid for — orthogonal to the +# ``Version`` tier, which only sets the hourly rate. One flat user-facing enum +# (the wire format splits it into PayType + AgentPlanConfig.BusinessScenarios); +# the personal/enterprise choice is always explicit, never inferred from the +# key or the seat. ``empty_pay`` exists server-side but is deliberately not +# offered: an unbound library's data plane is unusable and the library is +# auto-cleaned after 30 days. +PAY_TYPE_MAP = { + "agentplan_personal": ("agentplan_pay", "agent_plan_personal"), + "agentplan_enterprise": ("agentplan_pay", "agent_plan_enterprise"), + "volc_pay": ("volc_pay", None), +} +PAY_TYPE_CHOICES = tuple(PAY_TYPE_MAP) + # Header names that extra_headers must never override: auth and content type are # owned by the client and a stray value would break the request. _PROTECTED_HEADERS = {"authorization", "content-type"} diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 8cee7bb7..deec55ab 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -114,6 +114,8 @@ def create_collection( project: Optional[str] = None, description: Optional[str] = None, openviking_version: Optional[str] = None, + pay_type: Optional[str] = None, + seat_id: Optional[str] = None, ) -> Dict[str, Any]: """⚠️ Creates a NEW, BILLABLE OpenViking collection (provisions a Helm release). @@ -122,6 +124,11 @@ def create_collection( AgentPlan deduction activated (otherwise ProductUnordered). Do NOT call speculatively. + ⚠️ BILLING: if pay_type/seat_id are BOTH omitted, the server defaults the + library to volc_pay — Volcano pay-as-you-go, billed in REAL MONEY to the + Volcano account, NOT AgentPlan AFP. Ask the user which billing they want + before creating without them. + Args: name: library name, regex ^[a-zA-Z][a-zA-Z0-9_]*$, length <= 64. vlm: optional VLM model config, e.g. {"ModelName": "...", "ApiKeyID": "..."}. @@ -130,13 +137,23 @@ def create_collection( AgentPlan key. ApiKeyID and ApiKey are mutually exclusive. embedding: optional embedding model config, same shape/defaults as vlm. source: model source — "agentplan" (default), "volcengine", or "codeplan". - version: library tier — "developer" (default) or "enterprise". The - enterprise tier has higher capacity and is billed at enterprise - rates (25 AFP baseline / 200k files, then tiered per 100k files - beyond). Any other value is rejected immediately with an error. + version: library tier — "developer" (default) or "enterprise". Sets the + RATE only (enterprise: 25 AFP baseline / 200k files, then tiered + per 100k files beyond); billing SOURCE is pay_type, orthogonal. project: project name; defaults to the configured project. description: optional, length <= 65535. openviking_version: optional image version. + pay_type: how the library is billed — "agentplan_personal" (personal + AgentPlan AFP deduction), "agentplan_enterprise" (an enterprise + seat's AFP pays; requires seat_id), or "volc_pay" (Volcano + pay-as-you-go, real money). Always an explicit user choice — + NEVER guess personal vs enterprise from the key. + seat_id: the AgentPlan enterprise seat that pays (e.g. "seat-2026..."). + Required with pay_type="agentplan_enterprise", forbidden + otherwise. The user must copy it manually from the Ark console + seat-management page — there is no lookup API, and the server + does NOT verify the seat exists: a typo only surfaces at the + next hourly deduction, which then disables the library. Returns: {"ResourceID": "...", "Success": true} @@ -151,6 +168,8 @@ def create_collection( project=project, description=description, openviking_version=openviking_version, + pay_type=pay_type, + seat_id=seat_id, ) except Exception as e: logger.error(f"create_collection failed: {e}") @@ -162,19 +181,33 @@ def update_collection( resource_id: str, description: Optional[str] = None, openviking_version: Optional[str] = None, + pay_type: Optional[str] = None, + seat_id: Optional[str] = None, ) -> Dict[str, Any]: """Update mutable fields of an OpenViking collection (UpdateOpenVikingCollection). Requires the AgentPlan key to be associated with the target library. CONFIRM WITH - THE USER before calling — this mutates a live library. The backend re-validates - model credentials on update, so VLM/Embedding are sent automatically using the - configured AgentPlan key. NOTE: an empty/whitespace description is a server-side - no-op — the description can only be overwritten with a non-empty value. + THE USER before calling — this mutates a live library. This is also the way to + SWITCH BILLING (volc_pay ↔ AgentPlan deduction, or re-bind a seat after it + was unbound); omitting both pay_type and seat_id leaves billing untouched. + The backend re-validates model credentials on update, so VLM/Embedding are + sent automatically using the configured AgentPlan key. NOTE: an + empty/whitespace description is a server-side no-op — the description can + only be overwritten with a non-empty value. Args: resource_id: target library ResourceID. description: new description, length <= 65535 (non-empty to take effect). openviking_version: new image version. + pay_type: new billing — "agentplan_personal" (personal AgentPlan AFP), + "agentplan_enterprise" (an enterprise seat's AFP; requires + seat_id), or "volc_pay" (Volcano pay-as-you-go, real money). + Always an explicit user choice; NEVER guess personal vs + enterprise from the key. + seat_id: the AgentPlan enterprise seat that pays. Required with + pay_type="agentplan_enterprise", forbidden otherwise. Copied + manually by the user from the Ark console seat-management page; + the server does NOT verify the seat exists. Returns: {"Success": true} @@ -184,6 +217,8 @@ def update_collection( resource_id, description=description, openviking_version=openviking_version, + pay_type=pay_type, + seat_id=seat_id, ) except Exception as e: logger.error(f"update_collection failed: {e}") From c97bb25e90852c87838730d8e0c1cc2deb1b44f0 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Thu, 30 Jul 2026 17:47:03 +0800 Subject: [PATCH 04/20] feat(openviking-controlplane): default create billing to agentplan_personal When create is called with no pay_type/seat_id, bind agentplan_personal client-side instead of falling through to the server default volc_pay. The two failure modes are asymmetric: a wrong personal binding is immediately visible and recoverable via update, while the volc_pay default silently bills real money. volc_pay now requires an explicit choice; the CLI note warns enterprise-seat-key accounts (no personal plan) that the default binding would fail deduction and disable the library. Verified live: stg bare create lands agent_plan_personal; prod (older backend without PaymentConfig support) silently ignores the injected field, so bare create keeps working there. --- .../README.md | 10 +++++-- .../README_zh.md | 7 +++-- .../skills/openviking-controlplane/SKILL.md | 12 ++++---- .../mcp_server_openviking_controlplane/cli.py | 29 ++++++++++--------- .../client.py | 8 +++++ .../server.py | 21 +++++++++----- 6 files changed, 56 insertions(+), 31 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 1a6b97ed..978bfb16 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -91,9 +91,13 @@ uv run ov-cp create --name my_kb uv run ov-cp create --name my_kb --version enterprise # billing (--pay-type): who pays for the library — orthogonal to --version, -# which only sets the rate. ⚠️ Omitted => the server defaults to volc_pay -# (Volcano pay-as-you-go, REAL MONEY), not AgentPlan AFP deduction. -uv run ov-cp create --name my_kb --pay-type agentplan_personal +# which only sets the rate. Omitted => defaults to agentplan_personal (AFP +# deduction from the account's personal AgentPlan). volc_pay (Volcano +# pay-as-you-go, REAL MONEY) must be chosen explicitly. ⚠️ Enterprise seat +# keys must not rely on the default (no personal plan => deduction fails and +# the library is disabled) — pass agentplan_enterprise + --seat-id. +uv run ov-cp create --name my_kb # = --pay-type agentplan_personal +uv run ov-cp create --name my_kb --pay-type volc_pay uv run ov-cp create --name my_kb --version enterprise \ --pay-type agentplan_enterprise --seat-id seat-2026xxxx # --seat-id: the enterprise seat that pays. Copy it manually from the Ark diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index e1c6dc57..7461b12f 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -84,8 +84,11 @@ uv run ov-cp create --name my_kb uv run ov-cp create --name my_kb --version enterprise # 计费方式(--pay-type):库由谁付钱——与 --version 正交(--version 只决定费率)。 -# ⚠️ 不传时服务端默认 volc_pay(火山官网按量,扣真金白银),不走 AgentPlan AFP 抵扣。 -uv run ov-cp create --name my_kb --pay-type agentplan_personal +# 不传时默认 agentplan_personal(用账号的个人版 AgentPlan 做 AFP 抵扣); +# volc_pay(火山官网按量,扣真金白银)必须显式指定。⚠️ 企业版席位 key 不要依赖 +# 默认值(账号没有个人版套餐时抵扣会失败、库被停用),请显式传 agentplan_enterprise + --seat-id。 +uv run ov-cp create --name my_kb # 等价于 --pay-type agentplan_personal +uv run ov-cp create --name my_kb --pay-type volc_pay uv run ov-cp create --name my_kb --version enterprise \ --pay-type agentplan_enterprise --seat-id seat-2026xxxx # --seat-id:付费的企业版席位,需自行从方舟控制台「席位管理」页复制—— diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 818af515..a3bebf98 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -83,17 +83,19 @@ PAYS. Both `create` and `update` take the same two flags (`update` is how you switch billing later, or re-bind after a seat was unbound). ```bash -ov-cp create --name my_kb --pay-type agentplan_personal # personal AFP pays +ov-cp create --name my_kb # default: personal AFP pays ov-cp create --name my_kb --version enterprise \ --pay-type agentplan_enterprise --seat-id seat-2026xxxx # that seat's AFP pays ov-cp create --name my_kb --pay-type volc_pay # explicit website PAYG ov-cp update --pay-type volc_pay # switch billing later ``` -- ⚠️ **Omitting `--pay-type` on create => the server defaults to `volc_pay`**: - Volcano pay-as-you-go, billed in REAL MONEY, not AgentPlan AFP. The CLI prints - a warning; always confirm with the user which billing they want. -- The personal/enterprise choice is always explicit — never guess it from the key. +- **Omitting `--pay-type` on create defaults to `agentplan_personal`** (AFP + deduction from the account's personal AgentPlan) — real-money `volc_pay` must + be an explicit choice. ⚠️ Accounts with no personal plan (e.g. enterprise seat + keys) must not rely on the default: the library binds a non-existent personal + plan, deduction fails and the library is disabled. The CLI prints a note. +- The personal/enterprise choice is otherwise explicit — never guess it from the key. - `--seat-id` is required with `agentplan_enterprise` and forbidden otherwise. The user must copy it manually from the Ark console seat-management page (no lookup API). The server does NOT verify the seat exists — a typo only diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 5a18ddc0..66a63050 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -177,11 +177,13 @@ def create_cmd( openviking_version: Optional[str] = typer.Option(None, help="Image version (optional)."), pay_type: Optional[str] = typer.Option( None, "--pay-type", - help="Billing: agentplan_personal (personal AgentPlan AFP deduction) | " - "agentplan_enterprise (an enterprise seat's AFP pays; requires " - "--seat-id) | volc_pay (Volcano pay-as-you-go). ⚠️ If omitted, the " - "server defaults to volc_pay — REAL MONEY billed to the Volcano " - "account, not AgentPlan AFP.", + help="Billing: agentplan_personal (personal AgentPlan AFP deduction; the " + "default when omitted) | agentplan_enterprise (an enterprise seat's " + "AFP pays; requires --seat-id) | volc_pay (Volcano pay-as-you-go — " + "REAL MONEY; must be chosen explicitly). ⚠️ Accounts with no " + "personal plan (e.g. enterprise seat keys) must not rely on the " + "default: the library would bind a non-existent personal plan, " + "deduction fails and the library is disabled.", click_type=click.Choice(PAY_TYPE_CHOICES), metavar="[agentplan_personal|agentplan_enterprise|volc_pay]", ), @@ -199,18 +201,19 @@ def create_cmd( For source=agentplan you can pass just --name: the model names default to the AgentPlan models and the model ApiKey falls back to --api-key / AGENTPLAN_API_KEY. - ⚠️ Billing: without --pay-type the server defaults the library to volc_pay - (Volcano pay-as-you-go, real money). For AgentPlan AFP deduction pass - --pay-type agentplan_personal, or --pay-type agentplan_enterprise --seat-id - seat-xxx. + ⚠️ Billing: without --pay-type the library defaults to agentplan_personal + (AFP deduction from the account's personal AgentPlan). Enterprise seat + keys must pass --pay-type agentplan_enterprise --seat-id seat-xxx; website + pay-as-you-go (real money) must be chosen explicitly with --pay-type volc_pay. """ client = _client(ctx) if not (pay_type or seat_id): typer.echo( - "warning: no --pay-type — the server will default this library to " - "volc_pay (Volcano pay-as-you-go, REAL MONEY, not AgentPlan AFP). " - "Pass --pay-type agentplan_personal or agentplan_enterprise for AFP " - "deduction.", + "note: no --pay-type — defaulting to agentplan_personal (AFP deduction " + "from the account's personal AgentPlan). If this account has no " + "personal plan (e.g. an enterprise seat key), deduction will fail and " + "the library will be unusable — pass --pay-type agentplan_enterprise " + "--seat-id ... (or volc_pay) instead.", err=True, ) vlm = _model_cfg(vlm_model, vlm_api_key_id, vlm_api_key, vlm_endpoint_id) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index b07f884b..0df51658 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -206,6 +206,14 @@ def create_collection( raise ValueError( f"invalid version {version!r}; expected one of {', '.join(VERSION_CHOICES)}" ) + # Billing default: when the caller specifies nothing, bind the personal + # AgentPlan instead of leaving PaymentConfig unset — the server-side + # default is volc_pay, which silently bills real money. A wrong personal + # binding is visible immediately and recoverable via update; silent cash + # billing is neither. Accounts without a personal plan must pass an + # explicit pay_type. + if pay_type is None and seat_id is None: + pay_type = "agentplan_personal" payment = build_payment_config(pay_type, seat_id) # Multi-credential create format: top-level Source is omitted (each model # carries its source inside Credentials[]). diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index deec55ab..52ccfa72 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -124,10 +124,14 @@ def create_collection( AgentPlan deduction activated (otherwise ProductUnordered). Do NOT call speculatively. - ⚠️ BILLING: if pay_type/seat_id are BOTH omitted, the server defaults the - library to volc_pay — Volcano pay-as-you-go, billed in REAL MONEY to the - Volcano account, NOT AgentPlan AFP. Ask the user which billing they want - before creating without them. + ⚠️ BILLING: if pay_type/seat_id are BOTH omitted, this client DEFAULTS the + library to "agentplan_personal" (AFP deduction from the account's personal + AgentPlan) — it deliberately does NOT fall through to the server default + volc_pay, which silently bills real money. Accounts with no personal plan + (e.g. enterprise seat keys) must pass pay_type="agentplan_enterprise" + + seat_id (or "volc_pay"); otherwise the library binds a non-existent + personal plan, deduction fails and the library is disabled. Confirm the + intended billing with the user before creating. Args: name: library name, regex ^[a-zA-Z][a-zA-Z0-9_]*$, length <= 64. @@ -144,10 +148,11 @@ def create_collection( description: optional, length <= 65535. openviking_version: optional image version. pay_type: how the library is billed — "agentplan_personal" (personal - AgentPlan AFP deduction), "agentplan_enterprise" (an enterprise - seat's AFP pays; requires seat_id), or "volc_pay" (Volcano - pay-as-you-go, real money). Always an explicit user choice — - NEVER guess personal vs enterprise from the key. + AgentPlan AFP deduction; the default when omitted), + "agentplan_enterprise" (an enterprise seat's AFP pays; + requires seat_id), or "volc_pay" (Volcano pay-as-you-go, real + money; must be chosen explicitly). NEVER guess personal vs + enterprise from the key. seat_id: the AgentPlan enterprise seat that pays (e.g. "seat-2026..."). Required with pay_type="agentplan_enterprise", forbidden otherwise. The user must copy it manually from the Ark console From 1c6387630b9dc79e177a676b36f67c4b578c9504 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Thu, 30 Jul 2026 17:51:13 +0800 Subject: [PATCH 05/20] docs(openviking-controlplane): formalize billing wording Replace the informal "real money" phrasing in help text, docstrings and docs with neutral wording: volc_pay charges are billed directly to the Volcano account (vs deducted from AgentPlan AFP). No behavior change. --- .../README.md | 7 ++++--- .../README_zh.md | 5 +++-- .../skills/openviking-controlplane/SKILL.md | 9 +++++---- .../mcp_server_openviking_controlplane/cli.py | 18 +++++++++--------- .../client.py | 12 +++++++----- .../server.py | 15 ++++++++------- 6 files changed, 36 insertions(+), 30 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 978bfb16..2d29495e 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -93,9 +93,10 @@ uv run ov-cp create --name my_kb --version enterprise # billing (--pay-type): who pays for the library — orthogonal to --version, # which only sets the rate. Omitted => defaults to agentplan_personal (AFP # deduction from the account's personal AgentPlan). volc_pay (Volcano -# pay-as-you-go, REAL MONEY) must be chosen explicitly. ⚠️ Enterprise seat -# keys must not rely on the default (no personal plan => deduction fails and -# the library is disabled) — pass agentplan_enterprise + --seat-id. +# pay-as-you-go, billed to the Volcano account) must be chosen explicitly. +# ⚠️ Enterprise seat keys must not rely on the default (no personal plan => +# deduction fails and the library is disabled) — pass agentplan_enterprise +# + --seat-id. uv run ov-cp create --name my_kb # = --pay-type agentplan_personal uv run ov-cp create --name my_kb --pay-type volc_pay uv run ov-cp create --name my_kb --version enterprise \ diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 7461b12f..7661259f 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -85,8 +85,9 @@ uv run ov-cp create --name my_kb --version enterprise # 计费方式(--pay-type):库由谁付钱——与 --version 正交(--version 只决定费率)。 # 不传时默认 agentplan_personal(用账号的个人版 AgentPlan 做 AFP 抵扣); -# volc_pay(火山官网按量,扣真金白银)必须显式指定。⚠️ 企业版席位 key 不要依赖 -# 默认值(账号没有个人版套餐时抵扣会失败、库被停用),请显式传 agentplan_enterprise + --seat-id。 +# volc_pay(火山官网按量,费用计入火山账号账单)必须显式指定。⚠️ 企业版席位 key +# 不要依赖默认值(账号没有个人版套餐时抵扣会失败、库被停用),请显式传 +# agentplan_enterprise + --seat-id。 uv run ov-cp create --name my_kb # 等价于 --pay-type agentplan_personal uv run ov-cp create --name my_kb --pay-type volc_pay uv run ov-cp create --name my_kb --version enterprise \ diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index a3bebf98..2103cf7a 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -91,10 +91,11 @@ ov-cp update --pay-type volc_pay # switch billing la ``` - **Omitting `--pay-type` on create defaults to `agentplan_personal`** (AFP - deduction from the account's personal AgentPlan) — real-money `volc_pay` must - be an explicit choice. ⚠️ Accounts with no personal plan (e.g. enterprise seat - keys) must not rely on the default: the library binds a non-existent personal - plan, deduction fails and the library is disabled. The CLI prints a note. + deduction from the account's personal AgentPlan) — `volc_pay` (billed to the + Volcano account) must be an explicit choice. ⚠️ Accounts with no personal plan + (e.g. enterprise seat keys) must not rely on the default: the library binds a + non-existent personal plan, deduction fails and the library is disabled. The + CLI prints a note. - The personal/enterprise choice is otherwise explicit — never guess it from the key. - `--seat-id` is required with `agentplan_enterprise` and forbidden otherwise. The user must copy it manually from the Ark console seat-management page diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 66a63050..e2ac9002 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -179,11 +179,11 @@ def create_cmd( None, "--pay-type", help="Billing: agentplan_personal (personal AgentPlan AFP deduction; the " "default when omitted) | agentplan_enterprise (an enterprise seat's " - "AFP pays; requires --seat-id) | volc_pay (Volcano pay-as-you-go — " - "REAL MONEY; must be chosen explicitly). ⚠️ Accounts with no " - "personal plan (e.g. enterprise seat keys) must not rely on the " - "default: the library would bind a non-existent personal plan, " - "deduction fails and the library is disabled.", + "AFP pays; requires --seat-id) | volc_pay (Volcano pay-as-you-go, " + "billed to the Volcano account; must be chosen explicitly). " + "⚠️ Accounts with no personal plan (e.g. enterprise seat keys) must " + "not rely on the default: the library would bind a non-existent " + "personal plan, deduction fails and the library is disabled.", click_type=click.Choice(PAY_TYPE_CHOICES), metavar="[agentplan_personal|agentplan_enterprise|volc_pay]", ), @@ -203,8 +203,8 @@ def create_cmd( ⚠️ Billing: without --pay-type the library defaults to agentplan_personal (AFP deduction from the account's personal AgentPlan). Enterprise seat - keys must pass --pay-type agentplan_enterprise --seat-id seat-xxx; website - pay-as-you-go (real money) must be chosen explicitly with --pay-type volc_pay. + keys must pass --pay-type agentplan_enterprise --seat-id seat-xxx; Volcano + pay-as-you-go billing must be chosen explicitly with --pay-type volc_pay. """ client = _client(ctx) if not (pay_type or seat_id): @@ -247,8 +247,8 @@ def update_cmd( None, "--pay-type", help="Switch billing: agentplan_personal (personal AgentPlan AFP) | " "agentplan_enterprise (an enterprise seat's AFP; requires --seat-id) " - "| volc_pay (Volcano pay-as-you-go, real money). Omit to leave " - "billing untouched.", + "| volc_pay (Volcano pay-as-you-go, billed to the Volcano account). " + "Omit to leave billing untouched.", click_type=click.Choice(PAY_TYPE_CHOICES), metavar="[agentplan_personal|agentplan_enterprise|volc_pay]", ), diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 0df51658..3fb6e5fa 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -31,7 +31,8 @@ def build_payment_config( ``agentplan_enterprise`` / ``volc_pay``); the wire split into PayType + BusinessScenarios happens here. Returns None when nothing was given — the server then defaults the library to ``volc_pay``: Volcano pay-as-you-go, - billed in real money to the Volcano account, NOT AgentPlan AFP. The server + with charges billed directly to the Volcano account rather than deducted + from AgentPlan AFP. The server only checks a SeatId is non-empty, not that it exists: a typo surfaces at the next hourly deduction, after which the library is disabled. """ @@ -208,10 +209,11 @@ def create_collection( ) # Billing default: when the caller specifies nothing, bind the personal # AgentPlan instead of leaving PaymentConfig unset — the server-side - # default is volc_pay, which silently bills real money. A wrong personal - # binding is visible immediately and recoverable via update; silent cash - # billing is neither. Accounts without a personal plan must pass an - # explicit pay_type. + # default is volc_pay, which would put the library on Volcano + # pay-as-you-go billing without an explicit decision. A wrong personal + # binding is visible immediately and recoverable via update; unintended + # account billing is neither. Accounts without a personal plan must + # pass an explicit pay_type. if pay_type is None and seat_id is None: pay_type = "agentplan_personal" payment = build_payment_config(pay_type, seat_id) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 52ccfa72..ef5ba3d6 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -127,7 +127,8 @@ def create_collection( ⚠️ BILLING: if pay_type/seat_id are BOTH omitted, this client DEFAULTS the library to "agentplan_personal" (AFP deduction from the account's personal AgentPlan) — it deliberately does NOT fall through to the server default - volc_pay, which silently bills real money. Accounts with no personal plan + volc_pay, which would place the library on Volcano pay-as-you-go billing + without an explicit decision. Accounts with no personal plan (e.g. enterprise seat keys) must pass pay_type="agentplan_enterprise" + seat_id (or "volc_pay"); otherwise the library binds a non-existent personal plan, deduction fails and the library is disabled. Confirm the @@ -150,9 +151,9 @@ def create_collection( pay_type: how the library is billed — "agentplan_personal" (personal AgentPlan AFP deduction; the default when omitted), "agentplan_enterprise" (an enterprise seat's AFP pays; - requires seat_id), or "volc_pay" (Volcano pay-as-you-go, real - money; must be chosen explicitly). NEVER guess personal vs - enterprise from the key. + requires seat_id), or "volc_pay" (Volcano pay-as-you-go, + billed to the Volcano account; must be chosen explicitly). + NEVER guess personal vs enterprise from the key. seat_id: the AgentPlan enterprise seat that pays (e.g. "seat-2026..."). Required with pay_type="agentplan_enterprise", forbidden otherwise. The user must copy it manually from the Ark console @@ -206,9 +207,9 @@ def update_collection( openviking_version: new image version. pay_type: new billing — "agentplan_personal" (personal AgentPlan AFP), "agentplan_enterprise" (an enterprise seat's AFP; requires - seat_id), or "volc_pay" (Volcano pay-as-you-go, real money). - Always an explicit user choice; NEVER guess personal vs - enterprise from the key. + seat_id), or "volc_pay" (Volcano pay-as-you-go, billed to the + Volcano account). Always an explicit user choice; NEVER guess + personal vs enterprise from the key. seat_id: the AgentPlan enterprise seat that pays. Required with pay_type="agentplan_enterprise", forbidden otherwise. Copied manually by the user from the Ark console seat-management page; From a0a9b2da3f5f73ed50a7d54beb56ef5ce3e95852 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 31 Jul 2026 13:37:07 +0800 Subject: [PATCH 06/20] fix(openviking-controlplane): validate HTTP header encoding Reject placeholder API keys and invalid extra headers before requests reaches its low-level Latin-1 encoder. Co-authored-by: TRAE CLI --- .../common/auth.py | 34 +++++++++++++++ .../config.py | 7 +++ .../tests/test_headers.py | 43 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_headers.py diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/common/auth.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/common/auth.py index 58a213f6..fdfeade4 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/common/auth.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/common/auth.py @@ -7,9 +7,42 @@ (e.g. AK/SK signing) without touching ``client.py`` or the tool/CLI layers. """ +import re from typing import Dict, Protocol +_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") + + +def validate_header_name(name: str, *, label: str = "HTTP header name") -> None: + """Reject names that ``requests`` cannot safely place on the wire.""" + if not _HEADER_NAME_RE.fullmatch(name): + raise ValueError(f"{label} must contain only valid ASCII HTTP token characters") + + +def validate_header_value( + value: str, + *, + label: str = "HTTP header value", + ascii_only: bool = False, +) -> None: + """Validate a value before ``requests`` reaches its Latin-1 encoder.""" + if "\r" in value or "\n" in value: + raise ValueError(f"{label} must not contain newline characters") + encoding = "ascii" if ascii_only else "latin-1" + try: + value.encode(encoding) + except UnicodeEncodeError: + if ascii_only: + raise ValueError( + f"{label} contains non-ASCII characters; replace placeholder text " + "with the real ASCII value" + ) from None + raise ValueError( + f"{label} contains characters that cannot be sent in an HTTP header" + ) from None + + class AuthProvider(Protocol): """Produces the auth/identity headers for a single control-plane request.""" @@ -33,6 +66,7 @@ def __init__(self, token: str): token = (token or "").strip() if token.lower().startswith("bearer "): token = token[len("bearer "):].strip() + validate_header_value(token, label="AgentPlan API key", ascii_only=True) self._token = token def auth_headers( diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py index 8ff6ec94..f8db4daa 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py @@ -3,6 +3,11 @@ from dataclasses import dataclass, field from typing import Dict, Optional +from mcp_server_openviking_controlplane.common.auth import ( + validate_header_name, + validate_header_value, +) + logger = logging.getLogger(__name__) # The control-plane TopAPI is compiled into the OpenViking data-plane cluster. @@ -90,6 +95,8 @@ def safe_extra_headers(self) -> Dict[str, str]: callers can merge them onto request headers without clobbering auth.""" safe: Dict[str, str] = {} for key, value in self.extra_headers.items(): + validate_header_name(key, label="extra header name") + validate_header_value(value, label=f"extra header {key!r}") if key.lower() in _PROTECTED_HEADERS: logger.warning("ignoring protected extra header: %s", key) continue diff --git a/server/mcp_server_openviking_controlplane/tests/test_headers.py b/server/mcp_server_openviking_controlplane/tests/test_headers.py new file mode 100644 index 00000000..63198e3f --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_headers.py @@ -0,0 +1,43 @@ +import unittest + +from mcp_server_openviking_controlplane.common.auth import BearerTokenAuth +from mcp_server_openviking_controlplane.config import ControlPlaneConfig + + +class HeaderValidationTest(unittest.TestCase): + def test_agentplan_api_key_rejects_non_ascii_placeholder(self): + with self.assertRaisesRegex( + ValueError, + "AgentPlan API key contains non-ASCII characters", + ): + BearerTokenAuth("ark-你的key") + + def test_agentplan_api_key_accepts_bearer_prefix(self): + auth = BearerTokenAuth("Bearer ark-real-key") + + self.assertEqual( + auth.auth_headers("POST", "/", {}, "{}"), + {"Authorization": "Bearer ark-real-key"}, + ) + + def test_extra_header_rejects_invalid_name(self): + config = ControlPlaneConfig( + api_key="ark-real-key", + extra_headers={"bad header": "value"}, + ) + + with self.assertRaisesRegex(ValueError, "extra header name"): + config.safe_extra_headers() + + def test_extra_header_rejects_non_latin1_value(self): + config = ControlPlaneConfig( + api_key="ark-real-key", + extra_headers={"x-tt-env": "泳道"}, + ) + + with self.assertRaisesRegex(ValueError, "extra header 'x-tt-env'"): + config.safe_extra_headers() + + +if __name__ == "__main__": + unittest.main() From 672f49ef469b9ecb403ced6e5f3666d742bc5d8a Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 31 Jul 2026 13:45:54 +0800 Subject: [PATCH 07/20] fix(openviking-controlplane): keep CLI parse errors concise Use Typer-native string enums so invalid tier and billing choices stay in the normal usage-error path instead of leaking tracebacks. Co-authored-by: TRAE CLI --- .../mcp_server_openviking_controlplane/cli.py | 35 +++++++------- .../tests/test_cli_errors.py | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_cli_errors.py diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index e2ac9002..f8c6b009 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -1,16 +1,14 @@ import json import logging +from enum import Enum from typing import Any, Dict, List, Optional -import click import typer from mcp_server_openviking_controlplane.client import ControlPlaneClient, ControlPlaneError from mcp_server_openviking_controlplane.config import ( DEFAULT_EMBEDDING_MODEL, DEFAULT_VLM_MODEL, - PAY_TYPE_CHOICES, - VERSION_CHOICES, build_config, parse_extra_headers, ) @@ -29,6 +27,17 @@ ) +class VersionOption(str, Enum): + DEVELOPER = "developer" + ENTERPRISE = "enterprise" + + +class PayTypeOption(str, Enum): + AGENTPLAN_PERSONAL = "agentplan_personal" + AGENTPLAN_ENTERPRISE = "agentplan_enterprise" + VOLC_PAY = "volc_pay" + + def _print(result: Any) -> None: typer.echo(json.dumps(result, indent=2, ensure_ascii=False)) @@ -157,12 +166,10 @@ def create_cmd( ctx: typer.Context, name: str = typer.Option(..., help="Library name ^[a-zA-Z][a-zA-Z0-9_]*$, <=64."), source: str = typer.Option("agentplan", help="Model source: agentplan | volcengine | codeplan."), - version: str = typer.Option( - "developer", + version: VersionOption = typer.Option( + VersionOption.DEVELOPER, help="Library tier: developer (default) | enterprise " "(higher capacity, billed at enterprise rates).", - click_type=click.Choice(VERSION_CHOICES), - metavar="[developer|enterprise]", ), vlm_model: str = typer.Option(DEFAULT_VLM_MODEL, help="VLM ModelName."), vlm_api_key_id: Optional[str] = typer.Option(None, help="VLM ApiKeyID (exclusive with --vlm-api-key)."), @@ -175,7 +182,7 @@ def create_cmd( project: Optional[str] = typer.Option(None, help="Project name (defaults to configured)."), description: Optional[str] = typer.Option(None, help="Description, <=65535 chars."), openviking_version: Optional[str] = typer.Option(None, help="Image version (optional)."), - pay_type: Optional[str] = typer.Option( + pay_type: Optional[PayTypeOption] = typer.Option( None, "--pay-type", help="Billing: agentplan_personal (personal AgentPlan AFP deduction; the " "default when omitted) | agentplan_enterprise (an enterprise seat's " @@ -184,8 +191,6 @@ def create_cmd( "⚠️ Accounts with no personal plan (e.g. enterprise seat keys) must " "not rely on the default: the library would bind a non-existent " "personal plan, deduction fails and the library is disabled.", - click_type=click.Choice(PAY_TYPE_CHOICES), - metavar="[agentplan_personal|agentplan_enterprise|volc_pay]", ), seat_id: Optional[str] = typer.Option( None, "--seat-id", @@ -225,11 +230,11 @@ def create_cmd( source=source, vlm=vlm, embedding=embedding, - version=version, + version=version.value, project=project, description=description, openviking_version=openviking_version, - pay_type=pay_type, + pay_type=pay_type.value if pay_type else None, seat_id=seat_id, ) ) @@ -243,14 +248,12 @@ def update_cmd( resource_id: str = typer.Argument(..., help="Target library ResourceID."), description: Optional[str] = typer.Option(None, help="New description, <=65535 chars."), openviking_version: Optional[str] = typer.Option(None, help="New image version."), - pay_type: Optional[str] = typer.Option( + pay_type: Optional[PayTypeOption] = typer.Option( None, "--pay-type", help="Switch billing: agentplan_personal (personal AgentPlan AFP) | " "agentplan_enterprise (an enterprise seat's AFP; requires --seat-id) " "| volc_pay (Volcano pay-as-you-go, billed to the Volcano account). " "Omit to leave billing untouched.", - click_type=click.Choice(PAY_TYPE_CHOICES), - metavar="[agentplan_personal|agentplan_enterprise|volc_pay]", ), seat_id: Optional[str] = typer.Option( None, "--seat-id", @@ -272,7 +275,7 @@ def update_cmd( resource_id, description=description, openviking_version=openviking_version, - pay_type=pay_type, + pay_type=pay_type.value if pay_type else None, seat_id=seat_id, ) ) diff --git a/server/mcp_server_openviking_controlplane/tests/test_cli_errors.py b/server/mcp_server_openviking_controlplane/tests/test_cli_errors.py new file mode 100644 index 00000000..b430d092 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_cli_errors.py @@ -0,0 +1,47 @@ +import unittest + +from typer.testing import CliRunner + +from mcp_server_openviking_controlplane.cli import app + + +class CliParseErrorTest(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + + def test_invalid_pay_type_is_a_short_usage_error(self): + result = self.runner.invoke( + app, + ["create", "--name", "demo", "--pay-type", "agentplan"], + ) + + self.assertEqual(result.exit_code, 2) + self.assertIn("Invalid value for '--pay-type'", result.output) + self.assertIn("agentplan_enterprise", result.output) + self.assertNotIn("Traceback", result.output) + + def test_invalid_version_is_a_short_usage_error(self): + result = self.runner.invoke( + app, + ["create", "--name", "demo", "--version", "premium"], + ) + + self.assertEqual(result.exit_code, 2) + self.assertIn("Invalid value for '--version'", result.output) + self.assertIn("enterprise", result.output) + self.assertNotIn("Traceback", result.output) + + def test_unknown_option_does_not_emit_a_traceback(self): + result = self.runner.invoke( + app, + ["create", "--name", "demo", "--sead-id", "seat-demo"], + ) + + self.assertEqual(result.exit_code, 2) + self.assertIn("No such option: --sead-id", result.output) + self.assertIn("--seat-id", result.output) + self.assertNotIn("Traceback", result.output) + + +if __name__ == "__main__": + unittest.main() From 537697d99b520041d3fc5595e42d6658584d3849 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 31 Jul 2026 14:00:06 +0800 Subject: [PATCH 08/20] feat(openviking-controlplane): expose hourly billing estimates Preserve EstimatedCosts while adding structured CNY, payment-source, and AgentPlan AFP-per-hour details to usage responses. Co-authored-by: TRAE CLI --- .../README.md | 5 ++ .../README_zh.md | 4 + .../skills/openviking-controlplane/SKILL.md | 6 +- .../client.py | 61 ++++++++++++- .../server.py | 6 +- .../tests/test_usage_billing.py | 86 +++++++++++++++++++ 6 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_usage_billing.py diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 2d29495e..bdfd11e5 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -120,6 +120,11 @@ uv run ov-cp user delete xiaohong --yes uv run ov-cp delete --yes ``` +`usage` preserves the backend's legacy `EstimatedCosts` field and also returns +`EstimatedBilling` with an explicit hourly period and CNY unit. For collections +paid by AgentPlan it includes the equivalent AFP deduction and payment scenario; +for `volc_pay` it reports CNY only. + Flags override env. The endpoint defaults to the public gateway; override it only for testing (e.g. against a port-forward) with `-e` / `VIKING_ENDPOINT` — `uv run ov-cp -e http://localhost:18080 list`. diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 7661259f..f32f87a8 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -109,6 +109,10 @@ uv run ov-cp user delete xiaohong --yes uv run ov-cp delete --yes ``` +`usage` 保留后端原有的 `EstimatedCosts` 字段,同时新增 `EstimatedBilling`, +明确费用为每小时 CNY 估值。AgentPlan 支付的库还会返回对应的 AFP 抵扣量和 +支付场景;`volc_pay` 只返回 CNY。 + 命令行参数优先于环境变量。端点默认指向公网网关;仅在测试时(如指向 port-forward)才用 `-e` / `VIKING_ENDPOINT` 覆盖:`uv run ov-cp -e http://localhost:18080 list`。 `ov-cp --help` 不需要任何配置即可运行。 diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 2103cf7a..07bdc083 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -37,7 +37,7 @@ export AGENTPLAN_API_KEY=ark-xxxxxxxx ```bash ov-cp list # list collections (optionally --project X) ov-cp get # collection info (Status, models, version, ...) -ov-cp usage # file counts / estimated cost +ov-cp usage # file counts / hourly CNY and AgentPlan AFP estimate ov-cp api-key # plaintext data-plane key {UserID, Role, ApiKey} ov-cp create --name my_kb # create a collection (see below) ov-cp update --description "..." # update fields / switch billing @@ -53,6 +53,10 @@ ov-cp user delete xiaohong --yes # revoke a user's credentia Output is JSON. Errors print `Error [Code]: Message` to stderr with exit code 1. `ov-cp --help` and `ov-cp --help` work without any config. +`usage` keeps `EstimatedCosts` for compatibility and adds `EstimatedBilling`. +That object identifies the hourly period and CNY estimate; AgentPlan-paid +collections also include the AFP amount and business scenario. + ## Creating a collection ⚠️ **Billable + requires the account to have AgentPlan deduction activated** (else diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 3fb6e5fa..dff13345 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -1,5 +1,6 @@ import json import logging +from decimal import Decimal, InvalidOperation from typing import Any, Dict, Optional import requests @@ -19,6 +20,53 @@ # Headers we never replay verbatim: requests recomputes them, or a stale value # breaks the request. We always send a freshly serialized JSON body. _DROP_HEADERS = {"content-length", "connection", "accept-encoding"} +_AFP_PER_CNY = Decimal("500") + + +def _format_decimal(value: Decimal) -> str: + """Render a decimal without scientific notation or insignificant zeroes.""" + rendered = format(value.normalize(), "f") + return rendered.rstrip("0").rstrip(".") if "." in rendered else rendered + + +def enrich_usage_billing( + usage: Dict[str, Any], + collection: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Add unit, period, payment source and AgentPlan AFP to legacy usage data.""" + if isinstance(usage.get("EstimatedBilling"), dict): + return usage + estimated_cost = usage.get("EstimatedCosts") + if estimated_cost is None: + return usage + + billing: Dict[str, Any] = { + "CNY": str(estimated_cost), + "Period": "hour", + } + payment = (collection or {}).get("PaymentConfig") + if isinstance(payment, dict): + pay_type = payment.get("PayType") + if pay_type: + billing["PayType"] = pay_type + agentplan = payment.get("AgentPlanConfig") + if isinstance(agentplan, dict): + scenario = agentplan.get("BusinessScenarios") + if scenario: + billing["BusinessScenarios"] = scenario + if pay_type == "agentplan_pay": + try: + billing["AFP"] = _format_decimal( + Decimal(str(estimated_cost)) * _AFP_PER_CNY + ) + except InvalidOperation: + logger.warning( + "cannot convert EstimatedCosts=%r to AgentPlan AFP", + estimated_cost, + ) + + usage["EstimatedBilling"] = billing + return usage def build_payment_config( @@ -289,7 +337,18 @@ def get_usage(self, resource_id: str) -> Dict[str, Any]: result = self._request("GetOpenVikingUsage", {"ResourceID": resource_id}) # AgentFileNum is not meaningful here; drop it from the returned usage. result.pop("AgentFileNum", None) - return result + collection: Optional[Dict[str, Any]] = None + try: + collection = self.get_collection(resource_id) + except (ControlPlaneError, requests.RequestException) as error: + # PaymentConfig was added after the usage API. Keep usage compatible + # with older deployments even when collection metadata is unavailable. + logger.debug( + "cannot load billing metadata for %s: %s", + resource_id, + error, + ) + return enrich_usage_billing(result, collection) def get_user_access(self, resource_id: str) -> Dict[str, Any]: # On the data-plane cluster the api-key action is registered as diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index ef5ba3d6..42236d62 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -72,8 +72,10 @@ def get_usage(resource_id: str) -> Dict[str, Any]: Returns: {"CurContextFileNum", "ResourcesFileNum", "UserFileNum", - "FreshTime" (Unix seconds), "EstimatedCosts"}. Counts are whole-library + - the three top-level dirs only; per-uri breakdown is not supported. + "FreshTime" (Unix seconds), "EstimatedCosts", "EstimatedBilling"}. + EstimatedBilling adds CNY / hour plus PayType and, for AgentPlan + payment, the equivalent AFP / hour. Counts are whole-library + the + three top-level dirs only; per-uri breakdown is not supported. """ try: return get_client().get_usage(resource_id) diff --git a/server/mcp_server_openviking_controlplane/tests/test_usage_billing.py b/server/mcp_server_openviking_controlplane/tests/test_usage_billing.py new file mode 100644 index 00000000..c335c049 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_usage_billing.py @@ -0,0 +1,86 @@ +import unittest +from unittest.mock import patch + +import requests + +from mcp_server_openviking_controlplane.client import ( + ControlPlaneClient, + enrich_usage_billing, +) +from mcp_server_openviking_controlplane.config import ControlPlaneConfig + + +class UsageBillingTest(unittest.TestCase): + def test_agentplan_usage_includes_afp_and_cny_per_hour(self): + usage = {"EstimatedCosts": "0.05"} + collection = { + "PaymentConfig": { + "PayType": "agentplan_pay", + "AgentPlanConfig": { + "BusinessScenarios": "agent_plan_enterprise", + }, + } + } + + result = enrich_usage_billing(usage, collection) + + self.assertEqual( + result["EstimatedBilling"], + { + "CNY": "0.05", + "Period": "hour", + "PayType": "agentplan_pay", + "BusinessScenarios": "agent_plan_enterprise", + "AFP": "25", + }, + ) + + def test_volc_usage_does_not_claim_an_afp_charge(self): + result = enrich_usage_billing( + {"EstimatedCosts": "0.05"}, + {"PaymentConfig": {"PayType": "volc_pay"}}, + ) + + self.assertEqual( + result["EstimatedBilling"], + { + "CNY": "0.05", + "Period": "hour", + "PayType": "volc_pay", + }, + ) + + def test_existing_server_billing_is_authoritative(self): + usage = { + "EstimatedCosts": "0.05", + "EstimatedBilling": {"AFP": "24", "Period": "hour"}, + } + + result = enrich_usage_billing( + usage, + {"PaymentConfig": {"PayType": "agentplan_pay"}}, + ) + + self.assertEqual(result["EstimatedBilling"]["AFP"], "24") + + def test_usage_survives_collection_metadata_failure(self): + client = ControlPlaneClient(ControlPlaneConfig(api_key="ark-real-key")) + with patch.object( + client, + "_request", + side_effect=[ + {"EstimatedCosts": "0.05", "AgentFileNum": 99}, + requests.ConnectionError("metadata unavailable"), + ], + ): + result = client.get_usage("ov-example") + + self.assertNotIn("AgentFileNum", result) + self.assertEqual( + result["EstimatedBilling"], + {"CNY": "0.05", "Period": "hour"}, + ) + + +if __name__ == "__main__": + unittest.main() From dd602bcc502e52cd3d1c487fab756544a708975e Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 31 Jul 2026 14:18:52 +0800 Subject: [PATCH 09/20] feat(openviking-controlplane): add adaptive terminal views Render structured Rich tables and panels on TTYs while preserving standard JSON for pipes, redirects, and explicit machine-output modes. Co-authored-by: TRAE CLI --- .../README.md | 13 + .../README_zh.md | 12 + .../pyproject.toml | 1 + .../skills/openviking-controlplane/SKILL.md | 8 +- .../mcp_server_openviking_controlplane/cli.py | 60 ++- .../output.py | 413 ++++++++++++++++++ .../tests/test_output.py | 115 +++++ .../uv.lock | 4 +- 8 files changed, 606 insertions(+), 20 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/output.py create mode 100644 server/mcp_server_openviking_controlplane/tests/test_output.py diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index bdfd11e5..8ac554db 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -120,6 +120,19 @@ uv run ov-cp user delete xiaohong --yes uv run ov-cp delete --yes ``` +When stdout is a terminal, `--output auto` (the default) renders structured +Rich views: tables for collection/user lists, sectioned detail panels for +`get`/`usage`, compact success cards for mutations, and a warning panel for +plaintext API keys. Piping or redirecting automatically keeps standard JSON: + +```bash +uv run ov-cp list # Rich table in a terminal +uv run ov-cp list | jq '.Collections' # standard JSON +uv run ov-cp --json list # force standard JSON +uv run ov-cp --output json-compact list +uv run ov-cp --output pretty list # force the terminal view +``` + `usage` preserves the backend's legacy `EstimatedCosts` field and also returns `EstimatedBilling` with an explicit hourly period and CNY unit. For collections paid by AgentPlan it includes the equivalent AFP deduction and payment scenario; diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index f32f87a8..508d67bf 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -109,6 +109,18 @@ uv run ov-cp user delete xiaohong --yes uv run ov-cp delete --yes ``` +默认的 `--output auto` 在 stdout 连接终端时使用 Rich 结构化视图: +库/用户列表显示为表格,`get`/`usage` 显示为分区详情卡片,写操作显示精简成功卡片, +明文 API Key 则显示敏感信息警告。管道和重定向会自动保持标准 JSON: + +```bash +uv run ov-cp list # 终端内显示 Rich 表格 +uv run ov-cp list | jq '.Collections' # 标准 JSON +uv run ov-cp --json list # 强制标准 JSON +uv run ov-cp --output json-compact list +uv run ov-cp --output pretty list # 强制终端视图 +``` + `usage` 保留后端原有的 `EstimatedCosts` 字段,同时新增 `EstimatedBilling`, 明确费用为每小时 CNY 估值。AgentPlan 支付的库还会返回对应的 AFP 抵扣量和 支付场景;`volc_pay` 只返回 CNY。 diff --git a/server/mcp_server_openviking_controlplane/pyproject.toml b/server/mcp_server_openviking_controlplane/pyproject.toml index aee5ee5b..7d401d89 100644 --- a/server/mcp_server_openviking_controlplane/pyproject.toml +++ b/server/mcp_server_openviking_controlplane/pyproject.toml @@ -8,6 +8,7 @@ license = {text = "MIT"} dependencies = [ "mcp[cli]>=1.5.0", "requests>=2.31.0", + "rich>=13.8.0", "typer>=0.12.0", ] diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 07bdc083..8c17962c 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -50,8 +50,12 @@ ov-cp user update xiaohong --role admin ov-cp user delete xiaohong --yes # revoke a user's credential ``` -Output is JSON. Errors print `Error [Code]: Message` to stderr with exit code 1. -`ov-cp --help` and `ov-cp --help` work without any config. +In a terminal, output defaults to structured Rich views. Pipes and redirects +automatically receive standard JSON, so `ov-cp list | jq ...` and command +substitution remain safe. Use the global `--json`, `--output json-compact`, or +`--output pretty` flags to force a mode. Errors print `Error [Code]: Message` to +stderr with exit code 1. `ov-cp --help` and `ov-cp --help` work without +any config. `usage` keeps `EstimatedCosts` for compatibility and adds `EstimatedBilling`. That object identifies the hourly period and CNY estimate; AgentPlan-paid diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index f8c6b009..663486f4 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -1,7 +1,7 @@ -import json import logging +from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import typer @@ -12,6 +12,7 @@ build_config, parse_extra_headers, ) +from mcp_server_openviking_controlplane.output import OutputMode, render_result logging.basicConfig( level=logging.WARNING, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" @@ -38,8 +39,15 @@ class PayTypeOption(str, Enum): VOLC_PAY = "volc_pay" -def _print(result: Any) -> None: - typer.echo(json.dumps(result, indent=2, ensure_ascii=False)) +@dataclass +class CliState: + client_factory: Callable[[], ControlPlaneClient] + output_mode: OutputMode + + +def _print(ctx: typer.Context, result: Any, view: str = "auto") -> None: + state: CliState = ctx.obj + render_result(result, output_mode=state.output_mode, view=view) def _fail(e: Exception) -> "typer.Exit": @@ -54,7 +62,8 @@ def _fail(e: Exception) -> "typer.Exit": def _client(ctx: typer.Context) -> ControlPlaneClient: """Build the shared client lazily so `--help` never needs valid config.""" try: - return ctx.obj() + state: CliState = ctx.obj + return state.client_factory() except Exception as e: raise _fail(e) @@ -100,6 +109,16 @@ def main_callback( "VIKING_EXTRA_HEADERS (CLI wins). E.g. -H 'x-tt-env: lujiakun' to " "route into a swim-lane.", ), + output: OutputMode = typer.Option( + OutputMode.AUTO, + "--output", + help="Output: auto (TTY view, JSON when piped) | pretty | json | json-compact.", + ), + json_output: bool = typer.Option( + False, + "--json", + help="Force standard JSON output (shortcut for --output json).", + ), ): """Stash a client factory on the context; commands build it on demand.""" @@ -115,7 +134,10 @@ def _factory() -> ControlPlaneClient: ) return ControlPlaneClient(config) - ctx.obj = _factory + ctx.obj = CliState( + client_factory=_factory, + output_mode=OutputMode.JSON if json_output else output, + ) @app.command("list") @@ -126,7 +148,7 @@ def list_cmd( """List collections under the account.""" client = _client(ctx) try: - _print(client.list_collections(project=project)) + _print(ctx, client.list_collections(project=project), "collections") except Exception as e: raise _fail(e) @@ -136,7 +158,7 @@ def get_cmd(ctx: typer.Context, resource_id: str = typer.Argument(..., help="Tar """Get basic info of a collection.""" client = _client(ctx) try: - _print(client.get_collection(resource_id)) + _print(ctx, client.get_collection(resource_id), "collection") except Exception as e: raise _fail(e) @@ -146,7 +168,7 @@ def usage_cmd(ctx: typer.Context, resource_id: str = typer.Argument(..., help="T """Get overall usage / file counts of a collection.""" client = _client(ctx) try: - _print(client.get_usage(resource_id)) + _print(ctx, client.get_usage(resource_id), "usage") except Exception as e: raise _fail(e) @@ -156,7 +178,7 @@ def api_key_cmd(ctx: typer.Context, resource_id: str = typer.Argument(..., help= """Get the plaintext data-plane API Key of a collection (default user).""" client = _client(ctx) try: - _print(client.get_user_access(resource_id)) + _print(ctx, client.get_user_access(resource_id), "api-key") except Exception as e: raise _fail(e) @@ -225,6 +247,7 @@ def create_cmd( embedding = _model_cfg(emb_model, emb_api_key_id, emb_api_key, emb_endpoint_id) try: _print( + ctx, client.create_collection( name=name, source=source, @@ -236,7 +259,8 @@ def create_cmd( openviking_version=openviking_version, pay_type=pay_type.value if pay_type else None, seat_id=seat_id, - ) + ), + "success", ) except Exception as e: raise _fail(e) @@ -271,13 +295,15 @@ def update_cmd( client = _client(ctx) try: _print( + ctx, client.update_collection( resource_id, description=description, openviking_version=openviking_version, pay_type=pay_type.value if pay_type else None, seat_id=seat_id, - ) + ), + "success", ) except Exception as e: raise _fail(e) @@ -299,7 +325,7 @@ def user_list_cmd( """List users under a collection (ApiKey is masked; use `api-key` for plaintext).""" client = _client(ctx) try: - _print(client.list_collection_users(resource_id)) + _print(ctx, client.list_collection_users(resource_id), "users") except Exception as e: raise _fail(e) @@ -314,7 +340,7 @@ def user_register_cmd( """Register a new user under a collection.""" client = _client(ctx) try: - _print(client.register_user(resource_id, user_id, role=role)) + _print(ctx, client.register_user(resource_id, user_id, role=role), "success") except Exception as e: raise _fail(e) @@ -329,7 +355,7 @@ def user_update_cmd( """Update a user under a collection (only passed fields change).""" client = _client(ctx) try: - _print(client.update_user(resource_id, user_id, role=role)) + _print(ctx, client.update_user(resource_id, user_id, role=role), "success") except Exception as e: raise _fail(e) @@ -349,7 +375,7 @@ def user_delete_cmd( abort=True, ) try: - _print(client.delete_user(resource_id, user_id)) + _print(ctx, client.delete_user(resource_id, user_id), "success") except Exception as e: raise _fail(e) @@ -368,7 +394,7 @@ def delete_cmd( abort=True, ) try: - _print(client.delete_collection(resource_id)) + _print(ctx, client.delete_collection(resource_id), "success") except Exception as e: raise _fail(e) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/output.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/output.py new file mode 100644 index 00000000..51353f64 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/output.py @@ -0,0 +1,413 @@ +import json +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Iterable, Optional + +from rich import box +from rich.console import Console, Group +from rich.json import JSON +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.tree import Tree + + +class OutputMode(str, Enum): + AUTO = "auto" + PRETTY = "pretty" + JSON = "json" + JSON_COMPACT = "json-compact" + + +def render_result( + result: Any, + *, + output_mode: OutputMode = OutputMode.AUTO, + view: str = "auto", + console: Optional[Console] = None, + is_terminal: Optional[bool] = None, +) -> None: + """Render interactive terminal output without changing piped JSON.""" + console = console or Console() + terminal = console.is_terminal if is_terminal is None else is_terminal + effective_mode = output_mode + if output_mode == OutputMode.AUTO: + effective_mode = OutputMode.PRETTY if terminal else OutputMode.JSON + + if effective_mode in (OutputMode.JSON, OutputMode.JSON_COMPACT): + indent = 2 if effective_mode == OutputMode.JSON else None + separators = None if indent else (",", ":") + print( + json.dumps( + result, + indent=indent, + ensure_ascii=False, + separators=separators, + ), + file=console.file, + ) + return + + console.print(_pretty_renderable(result, view)) + + +def _pretty_renderable(result: Any, view: str) -> Any: + if view == "collections" and isinstance(result, dict): + return _collections_table(result.get("Collections")) + if view == "users" and isinstance(result, dict): + return _users_table(result.get("UserList"), result.get("Total")) + if view == "usage" and isinstance(result, dict): + return _usage_panel(result) + if view == "collection" and isinstance(result, dict): + return _collection_panel(result) + if view == "api-key" and isinstance(result, dict): + return _api_key_panel(result) + if view == "success" and isinstance(result, dict): + return _success_panel(result) + return _generic_renderable(result) + + +def _collections_table(rows: Any) -> Any: + if not isinstance(rows, list): + return _generic_renderable({"Collections": rows}) + table = Table( + title=f"OpenViking Collections ({len(rows)})", + box=box.ROUNDED, + header_style="bold cyan", + show_lines=False, + ) + table.add_column("Name", style="bold") + table.add_column("Resource ID", overflow="fold") + table.add_column("Tier") + table.add_column("Status") + table.add_column("Payment") + table.add_column("Project") + for row in rows: + if not isinstance(row, dict): + continue + payment = row.get("PaymentConfig") + table.add_row( + _text(row.get("Name")), + _text(row.get("ResourceID")), + _text(row.get("Version")), + _status_text(row.get("Status")), + _payment_label(payment), + _text(row.get("Project")), + ) + return table + + +def _users_table(rows: Any, total: Any) -> Any: + if not isinstance(rows, list): + return _generic_renderable({"UserList": rows, "Total": total}) + count = total if total is not None else len(rows) + table = Table( + title=f"Collection Users ({count})", + box=box.ROUNDED, + header_style="bold cyan", + ) + table.add_column("User ID", style="bold") + table.add_column("Role") + table.add_column("API Key") + for row in rows: + if not isinstance(row, dict): + continue + table.add_row( + _text(row.get("UserID")), + _text(row.get("Role")), + _text(row.get("ApiKey")), + ) + return table + + +def _usage_panel(result: Dict[str, Any]) -> Panel: + files = Table.grid(padding=(0, 2)) + files.add_column(style="dim", no_wrap=True) + files.add_column(justify="right") + files.add_row("Total context files", _number(result.get("CurContextFileNum"))) + files.add_row("Resources", _number(result.get("ResourcesFileNum"))) + files.add_row("User files", _number(result.get("UserFileNum"))) + files.add_row("Updated", _timestamp(result.get("FreshTime"))) + + billing = result.get("EstimatedBilling") + billing_table = Table.grid(padding=(0, 2)) + billing_table.add_column(style="dim", no_wrap=True) + billing_table.add_column() + if isinstance(billing, dict): + billing_table.add_row("Payment", _payment_label(billing)) + if billing.get("AFP") is not None: + billing_table.add_row( + "AFP deduction", + f"{_text(billing.get('AFP'))} AFP / {_period(billing)}", + ) + if billing.get("CNY") is not None: + billing_table.add_row( + "CNY equivalent", + f"¥{_text(billing.get('CNY'))} / {_period(billing)}", + ) + else: + billing_table.add_row( + "Estimated cost", + f"¥{_text(result.get('EstimatedCosts'))} / hour", + ) + + content = Group( + Text("Context Files", style="bold cyan"), + files, + Text(""), + Text("Estimated Billing", style="bold cyan"), + billing_table, + ) + return Panel(content, title="OpenViking Usage", border_style="cyan") + + +def _collection_panel(result: Dict[str, Any]) -> Panel: + identity_keys = ( + "Name", + "ResourceID", + "Status", + "Version", + "Project", + "Creator", + "Description", + ) + runtime_keys = ( + "OpenvikingVersion", + "OpenvikingVersionDesc", + "CreateTime", + "UpdateTime", + ) + identity = _property_table(result, identity_keys) + runtime = _property_table(result, runtime_keys) + sections: list[Any] = [ + Text("Collection", style="bold cyan"), + identity, + Text(""), + Text("Runtime", style="bold cyan"), + runtime, + ] + payment = result.get("PaymentConfig") + if isinstance(payment, dict): + sections.extend( + [ + Text(""), + Text("Payment", style="bold cyan"), + _property_table( + { + "PayType": _payment_label(payment), + "BusinessScenarios": _business_scenario(payment), + "SeatId": _seat_id(payment), + }, + ("PayType", "BusinessScenarios", "SeatId"), + ), + ] + ) + remaining = { + key: value + for key, value in result.items() + if key not in identity_keys + and key not in runtime_keys + and key != "PaymentConfig" + } + if remaining: + sections.extend( + [ + Text(""), + Text("Configuration", style="bold cyan"), + _nested_tree(remaining), + ] + ) + return Panel(Group(*sections), title=_text(result.get("Name"), "Collection")) + + +def _api_key_panel(result: Dict[str, Any]) -> Panel: + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", no_wrap=True) + details.add_column(overflow="fold") + details.add_row("User ID", _text(result.get("UserID"))) + details.add_row("Role", _text(result.get("Role"))) + details.add_row("API Key", Text(_text(result.get("ApiKey")), style="bold yellow")) + return Panel( + Group( + Text("Sensitive credential — do not paste it into logs or commits.", style="bold red"), + Text(""), + details, + ), + title="Collection API Key", + border_style="yellow", + ) + + +def _success_panel(result: Dict[str, Any]) -> Panel: + details = Table.grid(padding=(0, 2)) + details.add_column(style="dim", no_wrap=True) + details.add_column(overflow="fold") + for key, value in result.items(): + if key == "Success": + continue + details.add_row(_label(key), _value(value, key)) + success = result.get("Success", True) + title = "✓ Operation completed" if success else "Operation result" + content: Any = details if details.row_count else Text("Success", style="bold green") + return Panel(content, title=title, border_style="green" if success else "yellow") + + +def _property_table(data: Dict[str, Any], keys: Iterable[str]) -> Table: + table = Table.grid(padding=(0, 2)) + table.add_column(style="dim", no_wrap=True) + table.add_column(overflow="fold") + for key in keys: + if key not in data or data[key] in (None, "", [], {}): + continue + table.add_row(_label(key), _value(data[key], key)) + if not table.row_count: + table.add_row("Details", "—") + return table + + +def _generic_renderable(result: Any) -> Any: + if isinstance(result, dict): + if _is_flat_dict(result): + return Panel(_property_table(result, result.keys()), border_style="cyan") + return _nested_tree(result) + if isinstance(result, list) and all(isinstance(item, dict) for item in result): + return _dict_list_table(result) + return JSON.from_data(result, ensure_ascii=False) + + +def _dict_list_table(rows: list[Dict[str, Any]]) -> Any: + if not rows: + return Panel(Text("No results", style="dim"), border_style="cyan") + keys = list(dict.fromkeys(key for row in rows for key in row)) + if not keys or len(keys) > 8: + return _nested_tree(rows) + table = Table(box=box.ROUNDED, header_style="bold cyan") + for key in keys: + table.add_column(_label(key), overflow="fold") + for row in rows: + table.add_row(*(_value(row.get(key), key) for key in keys)) + return table + + +def _nested_tree(result: Any) -> Tree: + tree = Tree("Result", guide_style="dim") + _add_tree_nodes(tree, result) + return tree + + +def _add_tree_nodes(tree: Tree, value: Any) -> None: + if isinstance(value, dict): + for key, item in value.items(): + if isinstance(item, (dict, list)): + branch = tree.add(Text(_label(key), style="bold cyan")) + _add_tree_nodes(branch, item) + else: + tree.add(Text.assemble((_label(key) + ": ", "dim"), _value(item, key))) + return + if isinstance(value, list): + for index, item in enumerate(value): + branch = tree.add(Text(f"[{index}]", style="bold cyan")) + _add_tree_nodes(branch, item) + return + tree.add(_value(value)) + + +def _is_flat_dict(value: Dict[str, Any]) -> bool: + return all(not isinstance(item, (dict, list)) for item in value.values()) + + +def _value(value: Any, key: str = "") -> str: + if key in {"CreateTime", "UpdateTime", "FreshTime", "LastDeductTime"}: + return _timestamp(value) + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return _text(value) + + +def _payment_label(payment: Any) -> str: + if not isinstance(payment, dict): + return "—" + pay_type = payment.get("PayType") + scenario = _business_scenario(payment) + if pay_type == "agentplan_pay": + if scenario == "agent_plan_enterprise": + return "AgentPlan Enterprise" + if scenario == "agent_plan_personal": + return "AgentPlan Personal" + return "AgentPlan" + if pay_type == "volc_pay": + return "Volcano PAYG" + if pay_type == "empty_pay": + return "Unbound" + return _text(pay_type) + + +def _business_scenario(payment: Dict[str, Any]) -> str: + scenario = payment.get("BusinessScenarios") + if scenario: + return _text(scenario) + agentplan = payment.get("AgentPlanConfig") + if isinstance(agentplan, dict): + return _text(agentplan.get("BusinessScenarios")) + return "—" + + +def _seat_id(payment: Dict[str, Any]) -> str: + seat_id = payment.get("SeatId") or payment.get("SeatID") + if seat_id: + return _text(seat_id) + agentplan = payment.get("AgentPlanConfig") + if isinstance(agentplan, dict): + return _text(agentplan.get("SeatId") or agentplan.get("SeatID")) + return "—" + + +def _status_text(value: Any) -> Text: + status = _text(value) + styles = { + "READY": "green", + "RUNNING": "green", + "INIT": "yellow", + "FAILED": "red", + "ERROR": "red", + } + return Text(status, style=styles.get(status.upper(), "")) + + +def _period(billing: Dict[str, Any]) -> str: + period = _text(billing.get("Period"), "hour") + return period + + +def _timestamp(value: Any) -> str: + if value in (None, "", 0, "0"): + return "—" + try: + return datetime.fromtimestamp(int(value)).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + except (TypeError, ValueError, OSError, OverflowError): + return _text(value) + + +def _number(value: Any) -> str: + try: + return f"{int(value):,}" + except (TypeError, ValueError): + return _text(value) + + +def _label(key: Any) -> str: + text = str(key).replace("_", " ") + result: list[str] = [] + for index, char in enumerate(text): + if index and char.isupper() and text[index - 1].islower(): + result.append(" ") + result.append(char) + return "".join(result).strip().title() + + +def _text(value: Any, default: str = "—") -> str: + if value is None or value == "": + return default + return str(value) diff --git a/server/mcp_server_openviking_controlplane/tests/test_output.py b/server/mcp_server_openviking_controlplane/tests/test_output.py new file mode 100644 index 00000000..ae9a9538 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_output.py @@ -0,0 +1,115 @@ +import json +import unittest +from io import StringIO + +from rich.console import Console + +from mcp_server_openviking_controlplane.output import OutputMode, render_result + + +class OutputRenderingTest(unittest.TestCase): + def render( + self, + data, + *, + mode=OutputMode.AUTO, + view="auto", + is_terminal=False, + width=100, + ): + stream = StringIO() + console = Console( + file=stream, + force_terminal=False, + color_system=None, + width=width, + ) + render_result( + data, + output_mode=mode, + view=view, + console=console, + is_terminal=is_terminal, + ) + return stream.getvalue() + + def test_auto_mode_keeps_piped_output_as_standard_json(self): + data = {"ResourceID": "ov-123", "中文": "值"} + + output = self.render(data, is_terminal=False) + + self.assertEqual(json.loads(output), data) + self.assertNotIn("\x1b[", output) + + def test_json_compact_is_machine_readable(self): + data = {"ResourceID": "ov-123", "Success": True} + + output = self.render(data, mode=OutputMode.JSON_COMPACT) + + self.assertEqual(output, '{"ResourceID":"ov-123","Success":true}\n') + + def test_auto_mode_uses_usage_panel_in_a_terminal(self): + data = { + "CurContextFileNum": 20000, + "ResourcesFileNum": 20000, + "UserFileNum": 0, + "FreshTime": 1785406248, + "EstimatedCosts": "0.05", + "EstimatedBilling": { + "CNY": "0.05", + "AFP": "25", + "Period": "hour", + "PayType": "agentplan_pay", + "BusinessScenarios": "agent_plan_enterprise", + }, + } + + output = self.render( + data, + view="usage", + is_terminal=True, + width=72, + ) + + self.assertIn("OpenViking Usage", output) + self.assertIn("25 AFP / hour", output) + self.assertIn("¥0.05 / hour", output) + self.assertNotIn('"EstimatedBilling"', output) + + def test_collection_table_wraps_in_a_narrow_terminal(self): + data = { + "Collections": [ + { + "Name": "demo", + "ResourceID": "ov-very-long-resource-id", + "Version": "enterprise", + "Status": "READY", + "Project": "default", + } + ] + } + + output = self.render( + data, + mode=OutputMode.PRETTY, + view="collections", + width=54, + ) + + self.assertIn("OpenViking Collections (1)", output) + self.assertIn("demo", output) + self.assertIn("READY", output) + + def test_api_key_view_warns_that_the_value_is_sensitive(self): + output = self.render( + {"UserID": "default", "Role": "admin", "ApiKey": "secret-key"}, + mode=OutputMode.PRETTY, + view="api-key", + ) + + self.assertIn("Sensitive credential", output) + self.assertIn("secret-key", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/mcp_server_openviking_controlplane/uv.lock b/server/mcp_server_openviking_controlplane/uv.lock index 1f207a93..63aded08 100644 --- a/server/mcp_server_openviking_controlplane/uv.lock +++ b/server/mcp_server_openviking_controlplane/uv.lock @@ -329,7 +329,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -469,6 +469,7 @@ source = { editable = "." } dependencies = [ { name = "mcp", extra = ["cli"] }, { name = "requests" }, + { name = "rich" }, { name = "typer" }, ] @@ -476,6 +477,7 @@ dependencies = [ requires-dist = [ { name = "mcp", extras = ["cli"], specifier = ">=1.5.0" }, { name = "requests", specifier = ">=2.31.0" }, + { name = "rich", specifier = ">=13.8.0" }, { name = "typer", specifier = ">=0.12.0" }, ] From 60d3f8b00f9cc5cf9006667728ad6d364df5d271 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 31 Jul 2026 15:55:07 +0800 Subject: [PATCH 10/20] fix(openviking-controlplane): align user management contracts Support per-user API key access and user-list filters while matching the backend register and API-key rotation request fields. Co-authored-by: TRAE CLI --- .../README.md | 17 +- .../README_zh.md | 16 +- .../skills/openviking-controlplane/SKILL.md | 18 +- .../mcp_server_openviking_controlplane/cli.py | 67 ++++- .../client.py | 67 +++-- .../server.py | 66 +++-- .../tests/test_user_contract.py | 244 ++++++++++++++++++ 7 files changed, 425 insertions(+), 70 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_user_contract.py diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 8ac554db..21ee9280 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -4,7 +4,7 @@ MCP server **and** CLI for the OpenViking control plane (topapi) — manage OV libraries (`Collection`). Both front-ends share one core (`client.py`), so a tool added once is available from MCP and the CLI alike. -Covers the 11 control-plane Actions: +Covers 11 collection lifecycle, billing, and user-management Actions: | Action | MCP tool | CLI command | |---|---|---| @@ -14,16 +14,17 @@ Covers the 11 control-plane Actions: | `UpdateOpenVikingCollection` | `update_collection` | `ov-cp update ` | | `DeleteOpenVikingCollection` | `delete_collection` ⚠️ | `ov-cp delete ` | | `GetOpenVikingUsage` | `get_usage` | `ov-cp usage ` | -| `GetOpenVikingCollectionUserAccess` | `get_collection_api_key` | `ov-cp api-key ` | -| `ListOpenVikingCollectionUser` | `list_collection_users` | `ov-cp user list ` | +| `AccessOpenVikingApiKey` (`/GetOpenVikingCollectionUserAccess`) | `get_collection_api_key` | `ov-cp api-key ` | +| `ListOpenVikingUser` (`/ListOpenVikingCollectionUser`) | `list_collection_users` | `ov-cp user list ` | | `RegisterOpenVikingUser` | `register_collection_user` | `ov-cp user register ` | | `UpdateOpenVikingUser` | `update_collection_user` | `ov-cp user update ` | | `DeleteOpenVikingUser` | `delete_collection_user` ⚠️ | `ov-cp user delete ` | The `user *` actions manage the multiple users of an enterprise-tier library; they require the AgentPlan key to be **associated with the target library**. A user's -`ApiKey` from `user list` is **masked** — fetch a plaintext data-plane key via -`api-key`. +`ApiKey` from `user list` is **masked** — fetch a selected user's plaintext +data-plane key via `api-key --user-id `. Newly registered users always +have role `user`; `user update` currently supports API Key rotation only. ## Endpoint @@ -82,6 +83,7 @@ uv run ov-cp list uv run ov-cp get uv run ov-cp usage uv run ov-cp api-key +uv run ov-cp api-key --user-id xiaohong # create (consumes paid quota; with source=agentplan only --name is needed — # model names default, and the model ApiKey falls back to the configured key) @@ -112,8 +114,9 @@ uv run ov-cp update --pay-type volc_pay # manage users of an enterprise-tier library (key must be associated with it) uv run ov-cp user list -uv run ov-cp user register xiaohong --role user -uv run ov-cp user update xiaohong --role admin +uv run ov-cp user list --role user --page 1 --limit 20 +uv run ov-cp user register xiaohong +uv run ov-cp user update xiaohong --regenerate-key uv run ov-cp user delete xiaohong --yes # delete (irreversible) diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 508d67bf..a8e2dd09 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -4,7 +4,7 @@ OpenViking 控制面(topapi)的 MCP Server **与** CLI —— 用于管理 O (`Collection`)。两个前端共用同一套核心(`client.py`),新增一个能力即可同时被 MCP 和 CLI 使用。 -覆盖 11 个控制面 Action: +覆盖 11 个库生命周期、计费与用户管理 Action: | Action | MCP tool | CLI 命令 | |---|---|---| @@ -14,14 +14,16 @@ OpenViking 控制面(topapi)的 MCP Server **与** CLI —— 用于管理 O | `UpdateOpenVikingCollection` | `update_collection` | `ov-cp update ` | | `DeleteOpenVikingCollection` | `delete_collection` ⚠️ | `ov-cp delete ` | | `GetOpenVikingUsage` | `get_usage` | `ov-cp usage ` | -| `GetOpenVikingCollectionUserAccess` | `get_collection_api_key` | `ov-cp api-key ` | -| `ListOpenVikingCollectionUser` | `list_collection_users` | `ov-cp user list ` | +| `AccessOpenVikingApiKey`(路径 `/GetOpenVikingCollectionUserAccess`) | `get_collection_api_key` | `ov-cp api-key ` | +| `ListOpenVikingUser`(路径 `/ListOpenVikingCollectionUser`) | `list_collection_users` | `ov-cp user list ` | | `RegisterOpenVikingUser` | `register_collection_user` | `ov-cp user register ` | | `UpdateOpenVikingUser` | `update_collection_user` | `ov-cp user update ` | | `DeleteOpenVikingUser` | `delete_collection_user` ⚠️ | `ov-cp user delete ` | `user *` 系列管理企业版库的多用户,要求 AgentPlan key **与目标库已关联**。`user list` -返回的用户 `ApiKey` 是**掩码**,取明文数据面 key 走 `api-key`。 +返回的用户 `ApiKey` 是**掩码**,取指定用户的明文数据面 key 使用 +`api-key --user-id `。新注册用户的角色固定为 `user`;`user update` +当前只支持重生 API Key。 ## 端点 @@ -75,6 +77,7 @@ uv run ov-cp list uv run ov-cp get uv run ov-cp usage uv run ov-cp api-key +uv run ov-cp api-key --user-id xiaohong # 建库(消耗付费配额;source=agentplan 时只需 --name, # 模型名取默认、模型 ApiKey 回落到配置的 key) @@ -101,8 +104,9 @@ uv run ov-cp update --pay-type volc_pay # 管理企业版库的用户(key 需与该库已关联) uv run ov-cp user list -uv run ov-cp user register xiaohong --role user -uv run ov-cp user update xiaohong --role admin +uv run ov-cp user list --role user --page 1 --limit 20 +uv run ov-cp user register xiaohong +uv run ov-cp user update xiaohong --regenerate-key uv run ov-cp user delete xiaohong --yes # 删库(不可逆) diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 8c17962c..7dd09819 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -38,18 +38,24 @@ export AGENTPLAN_API_KEY=ark-xxxxxxxx ov-cp list # list collections (optionally --project X) ov-cp get # collection info (Status, models, version, ...) ov-cp usage # file counts / hourly CNY and AgentPlan AFP estimate -ov-cp api-key # plaintext data-plane key {UserID, Role, ApiKey} +ov-cp api-key # default user's plaintext data-plane key +ov-cp api-key --user-id xiaohong # selected user's plaintext key ov-cp create --name my_kb # create a collection (see below) ov-cp update --description "..." # update fields / switch billing ov-cp delete --yes # delete (irreversible; uninstalls the Helm release) # users of an enterprise-tier library (key must be associated with the library): ov-cp user list # users (ApiKey is masked) -ov-cp user register xiaohong --role user # add a user (UserID + role) -ov-cp user update xiaohong --role admin +ov-cp user list --role user --page 1 --limit 20 +ov-cp user register xiaohong # new users always get role=user +ov-cp user update xiaohong --regenerate-key ov-cp user delete xiaohong --yes # revoke a user's credential ``` +After `user update --regenerate-key`, fetch the replacement with +`api-key --user-id `; the update response only confirms +success and does not contain the new key. + In a terminal, output defaults to structured Rich views. Pipes and redirects automatically receive standard JSON, so `ov-cp list | jq ...` and command substitution remain safe. Use the global `--json`, `--output json-compact`, or @@ -129,14 +135,14 @@ The returned `ApiKey` is the library's **data-plane** key. Use it as ## Notes - Only `Authorization: Bearer` is accepted (no `X-API-Key`). -- Read-only actions (list/get/usage/delete) are not gated by AgentPlan; create and - api-key are. +- `list` / `get` / `usage` are read-only. `delete` is destructive but, like those + reads, is not gated by AgentPlan; `create` and `api-key` are gated. - `get`/`usage`/`api-key`/`delete`/`update` and all `user *` take a `ResourceID` (e.g. `ov-xxxxxxxx`). - `user *` manages the multiple users of an **enterprise-tier** library and needs the AgentPlan key to be **associated with that library** (else the backend rejects it). `user list` returns each user's **masked** ApiKey; for a plaintext data-plane key - use `api-key`. + use `api-key --user-id `. - Extra headers: pass `-H 'Key: Value'` (repeatable) or set `VIKING_EXTRA_HEADERS` to a comma-separated `Key: Value` list — e.g. `-H 'x-tt-env: lujiakun'` for swim-lane routing. `Authorization` / `Content-Type` are protected and ignored. diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 663486f4..1ca521e1 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -174,11 +174,19 @@ def usage_cmd(ctx: typer.Context, resource_id: str = typer.Argument(..., help="T @app.command("api-key") -def api_key_cmd(ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID.")): - """Get the plaintext data-plane API Key of a collection (default user).""" +def api_key_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + user_id: Optional[str] = typer.Option( + None, + "--user-id", + help="Target UserID; omit for the default user.", + ), +): + """Get a user's plaintext data-plane API Key.""" client = _client(ctx) try: - _print(ctx, client.get_user_access(resource_id), "api-key") + _print(ctx, client.get_user_access(resource_id, user_id=user_id), "api-key") except Exception as e: raise _fail(e) @@ -321,11 +329,33 @@ def update_cmd( def user_list_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), + user_id: Optional[str] = typer.Option( + None, + "--user-id", + help="Filter by exact UserID.", + ), + role: Optional[str] = typer.Option( + None, + "--role", + help="Filter by role, e.g. admin | user.", + ), + page: int = typer.Option(1, min=1, help="Page number (1-based)."), + limit: int = typer.Option(20, min=1, max=200, help="Users per page."), ): """List users under a collection (ApiKey is masked; use `api-key` for plaintext).""" client = _client(ctx) try: - _print(ctx, client.list_collection_users(resource_id), "users") + _print( + ctx, + client.list_collection_users( + resource_id, + user_id=user_id, + role=role, + page=page, + limit=limit, + ), + "users", + ) except Exception as e: raise _fail(e) @@ -335,12 +365,11 @@ def user_register_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), user_id: str = typer.Argument(..., help="UserID for the new user (unique in library)."), - role: Optional[str] = typer.Option(None, help="Role, e.g. admin | user."), ): - """Register a new user under a collection.""" + """Register a new regular user under a collection.""" client = _client(ctx) try: - _print(ctx, client.register_user(resource_id, user_id, role=role), "success") + _print(ctx, client.register_user(resource_id, user_id), "success") except Exception as e: raise _fail(e) @@ -350,12 +379,30 @@ def user_update_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), user_id: str = typer.Argument(..., help="Target UserID."), - role: Optional[str] = typer.Option(None, help="New role, e.g. admin | user."), + regenerate_key: bool = typer.Option( + False, + "--regenerate-key", + help="Rotate the user's data-plane API Key.", + ), ): - """Update a user under a collection (only passed fields change).""" + """Update a user under a collection (currently API Key rotation only).""" + if not regenerate_key: + raise _fail( + ValueError( + "nothing to update: pass --regenerate-key to rotate the user's API Key" + ) + ) client = _client(ctx) try: - _print(ctx, client.update_user(resource_id, user_id, role=role), "success") + _print( + ctx, + client.update_user( + resource_id, + user_id, + regenerate_key=regenerate_key, + ), + "success", + ) except Exception as e: raise _fail(e) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index dff13345..90d7f617 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -350,39 +350,60 @@ def get_usage(self, resource_id: str) -> Dict[str, Any]: ) return enrich_usage_billing(result, collection) - def get_user_access(self, resource_id: str) -> Dict[str, Any]: + def get_user_access( + self, + resource_id: str, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: # On the data-plane cluster the api-key action is registered as # GetOpenVikingCollectionUserAccess (the console proxy's - # AccessOpenVikingApiKey is NOT routed here — it 404s). Returns the - # default user's PLAINTEXT key: {"UserID", "Role", "ApiKey"}. + # AccessOpenVikingApiKey is NOT routed here — it 404s). Returns a + # PLAINTEXT key: {"UserID", "Role", "ApiKey"}. With no UserID the + # backend returns the default user. # (ListOpenVikingCollectionUser only returns a masked key.) - return self._request( - "GetOpenVikingCollectionUserAccess", {"ResourceID": resource_id} - ) + body: Dict[str, Any] = {"ResourceID": resource_id} + if user_id is not None: + body["UserID"] = user_id + return self._request("GetOpenVikingCollectionUserAccess", body) # --- User management (enterprise-tier libraries: multi-user) ------------- # These require the AgentPlan key to be associated with the target library; # operating on an unassociated library is rejected server-side. The ApiKey in # a List response is MASKED — fetch the plaintext key via get_user_access. - def list_collection_users(self, resource_id: str) -> Dict[str, Any]: + def list_collection_users( + self, + resource_id: str, + user_id: Optional[str] = None, + role: Optional[str] = None, + page: int = 1, + limit: int = 20, + ) -> Dict[str, Any]: # ListOpenVikingCollectionUser: users under the library (ApiKey masked). - return self._request( - "ListOpenVikingCollectionUser", {"ResourceID": resource_id} - ) + if page < 1: + raise ValueError("page must be >= 1") + if not 1 <= limit <= 200: + raise ValueError("limit must be between 1 and 200") + body: Dict[str, Any] = { + "ResourceID": resource_id, + "Page": page, + "Limit": limit, + } + if user_id is not None: + body["UserID"] = user_id + if role is not None: + body["Role"] = role + return self._request("ListOpenVikingCollectionUser", body) def register_user( self, resource_id: str, user_id: str, - role: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: - # RegisterOpenVikingUser: create a new user under the library. UserID is - # required; Role is e.g. "admin" / "user". + # RegisterOpenVikingUser: create a regular "user" under the library. + # The backend does not accept a Role parameter. body: Dict[str, Any] = {"ResourceID": resource_id, "UserID": user_id} - if role is not None: - body["Role"] = role if extra: body.update(extra) return self._request("RegisterOpenVikingUser", body) @@ -391,13 +412,19 @@ def update_user( self, resource_id: str, user_id: str, - role: Optional[str] = None, + regenerate_key: bool = False, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: - # UpdateOpenVikingUser: update a user's mutable fields (e.g. Role). - body: Dict[str, Any] = {"ResourceID": resource_id, "UserID": user_id} - if role is not None: - body["Role"] = role + # UpdateOpenVikingUser only supports rotating the user's ApiKey. + if not regenerate_key: + raise ValueError( + "nothing to update: regenerate_key=True is required to rotate the user's API Key" + ) + body: Dict[str, Any] = { + "ResourceID": resource_id, + "UserID": user_id, + "RegenerateKey": True, + } if extra: body.update(extra) return self._request("UpdateOpenVikingUser", body) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 42236d62..5317860a 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -85,22 +85,27 @@ def get_usage(resource_id: str) -> Dict[str, Any]: @mcp.tool() -def get_collection_api_key(resource_id: str) -> Dict[str, Any]: - """Get the plaintext data-plane API Key of one collection by ResourceID. +def get_collection_api_key( + resource_id: str, + user_id: Optional[str] = None, +) -> Dict[str, Any]: + """Get one user's plaintext data-plane API Key. - Backed by the action GetOpenVikingCollectionUserAccess. Returns the library's - default-user credential. You can only query libraries under your own account; - there is no cross-account / sudo lookup. NOTE: the ApiKey is plaintext — handle - and surface it with care. + Backed by the action GetOpenVikingCollectionUserAccess. When user_id is omitted, + returns the library's default-user credential; enterprise libraries can select + a specific user. You can only query libraries under your own account; there is + no cross-account / sudo lookup. NOTE: the ApiKey is plaintext — handle and + surface it with care. Args: resource_id: target library ResourceID. + user_id: optional target UserID; omit for the default user. Returns: {"UserID", "Role", "ApiKey"} """ try: - return get_client().get_user_access(resource_id) + return get_client().get_user_access(resource_id, user_id=user_id) except Exception as e: logger.error(f"get_collection_api_key failed: {e}") return _err(e) @@ -234,7 +239,13 @@ def update_collection( @mcp.tool() -def list_collection_users(resource_id: str) -> Dict[str, Any]: +def list_collection_users( + resource_id: str, + user_id: Optional[str] = None, + role: Optional[str] = None, + page: int = 1, + limit: int = 20, +) -> Dict[str, Any]: """List the users registered under one OpenViking collection. Backed by ListOpenVikingCollectionUser. Requires the AgentPlan key to be @@ -243,36 +254,44 @@ def list_collection_users(resource_id: str) -> Dict[str, Any]: Args: resource_id: target library ResourceID. + user_id: optional exact UserID filter. + role: optional role filter, e.g. "admin" or "user". + page: 1-based page number; defaults to 1. + limit: users per page, 1 to 200; defaults to 20. Returns: {"UserList": [ {"UserID", "Role", "ApiKey" (masked)} ], "Total": N} """ try: - return get_client().list_collection_users(resource_id) + return get_client().list_collection_users( + resource_id, + user_id=user_id, + role=role, + page=page, + limit=limit, + ) except Exception as e: logger.error(f"list_collection_users failed: {e}") return _err(e) @mcp.tool() -def register_collection_user( - resource_id: str, user_id: str, role: Optional[str] = None -) -> Dict[str, Any]: +def register_collection_user(resource_id: str, user_id: str) -> Dict[str, Any]: """Register a NEW user under an OpenViking collection (RegisterOpenVikingUser). Requires the AgentPlan key to be associated with the target library. CONFIRM - WITH THE USER before calling — this creates a new credentialed user. + WITH THE USER before calling — this creates a new credentialed regular "user". + The backend does not support choosing another role. Args: resource_id: target library ResourceID. user_id: the UserID for the new user (unique within the library). - role: optional role, e.g. "admin" or "user". Returns: {"Success": true} """ try: - return get_client().register_user(resource_id, user_id, role=role) + return get_client().register_user(resource_id, user_id) except Exception as e: logger.error(f"register_collection_user failed: {e}") return _err(e) @@ -282,23 +301,28 @@ def register_collection_user( def update_collection_user( resource_id: str, user_id: str, - role: Optional[str] = None, + regenerate_key: bool, ) -> Dict[str, Any]: - """Update a user under an OpenViking collection (UpdateOpenVikingUser). + """Update a user under an OpenViking collection (currently API Key rotation). - Only the fields you pass (non-None) are changed. Requires the AgentPlan key to be - associated with the target library. CONFIRM WITH THE USER before calling. + Requires the AgentPlan key to be associated with the target library. CONFIRM + WITH THE USER before calling with regenerate_key=true because the old key stops + working. The backend currently has no role-update operation. Args: resource_id: target library ResourceID. user_id: the UserID to update. - role: optional new role, e.g. "admin" or "user". + regenerate_key: true to rotate the user's data-plane API Key. Returns: {"Success": true} """ try: - return get_client().update_user(resource_id, user_id, role=role) + return get_client().update_user( + resource_id, + user_id, + regenerate_key=regenerate_key, + ) except Exception as e: logger.error(f"update_collection_user failed: {e}") return _err(e) diff --git a/server/mcp_server_openviking_controlplane/tests/test_user_contract.py b/server/mcp_server_openviking_controlplane/tests/test_user_contract.py new file mode 100644 index 00000000..f9839404 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_user_contract.py @@ -0,0 +1,244 @@ +import unittest +from unittest.mock import patch + +from typer.testing import CliRunner + +from mcp_server_openviking_controlplane.cli import app +from mcp_server_openviking_controlplane.client import ControlPlaneClient +from mcp_server_openviking_controlplane.config import ControlPlaneConfig +from mcp_server_openviking_controlplane import server + + +class UserContractTest(unittest.TestCase): + def setUp(self): + self.client = ControlPlaneClient(ControlPlaneConfig(api_key="ark-test")) + + def test_get_user_access_can_select_user(self): + with patch.object( + self.client, + "_request", + return_value={"UserID": "alice", "Role": "user", "ApiKey": "plain"}, + ) as request: + result = self.client.get_user_access("ov-example", user_id="alice") + + self.assertEqual(result["UserID"], "alice") + request.assert_called_once_with( + "GetOpenVikingCollectionUserAccess", + {"ResourceID": "ov-example", "UserID": "alice"}, + ) + + def test_get_user_access_omits_user_for_default(self): + with patch.object(self.client, "_request", return_value={}) as request: + self.client.get_user_access("ov-example") + + request.assert_called_once_with( + "GetOpenVikingCollectionUserAccess", + {"ResourceID": "ov-example"}, + ) + + def test_list_users_forwards_filters_and_pagination(self): + with patch.object( + self.client, + "_request", + return_value={"UserList": [], "Total": 0}, + ) as request: + self.client.list_collection_users( + "ov-example", + user_id="alice", + role="user", + page=2, + limit=10, + ) + + request.assert_called_once_with( + "ListOpenVikingCollectionUser", + { + "ResourceID": "ov-example", + "UserID": "alice", + "Role": "user", + "Page": 2, + "Limit": 10, + }, + ) + + def test_list_users_validates_pagination_locally(self): + with self.assertRaisesRegex(ValueError, "page must be >= 1"): + self.client.list_collection_users("ov-example", page=0) + with self.assertRaisesRegex(ValueError, "limit must be between 1 and 200"): + self.client.list_collection_users("ov-example", limit=201) + + def test_register_user_does_not_send_unsupported_role(self): + with patch.object(self.client, "_request", return_value={"Success": True}) as request: + self.client.register_user("ov-example", "alice") + + request.assert_called_once_with( + "RegisterOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice"}, + ) + + def test_update_user_sends_regenerate_key(self): + with patch.object(self.client, "_request", return_value={"Success": True}) as request: + self.client.update_user( + "ov-example", + "alice", + regenerate_key=True, + ) + + request.assert_called_once_with( + "UpdateOpenVikingUser", + { + "ResourceID": "ov-example", + "UserID": "alice", + "RegenerateKey": True, + }, + ) + + def test_update_user_rejects_no_op(self): + with self.assertRaisesRegex(ValueError, "nothing to update"): + self.client.update_user("ov-example", "alice") + + +class UserCliContractTest(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + + def test_api_key_accepts_user_id(self): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.get_user_access", + return_value={"UserID": "alice", "Role": "user", "ApiKey": "plain"}, + ) as get_user_access: + result = self.runner.invoke( + app, + [ + "--api-key", + "ark-test", + "--json", + "api-key", + "ov-example", + "--user-id", + "alice", + ], + ) + + self.assertEqual(result.exit_code, 0) + get_user_access.assert_called_once_with("ov-example", user_id="alice") + + def test_user_list_accepts_filters_and_pagination(self): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.list_collection_users", + return_value={"UserList": [], "Total": 0}, + ) as list_users: + result = self.runner.invoke( + app, + [ + "--api-key", + "ark-test", + "--json", + "user", + "list", + "ov-example", + "--user-id", + "alice", + "--role", + "user", + "--page", + "2", + "--limit", + "10", + ], + ) + + self.assertEqual(result.exit_code, 0) + list_users.assert_called_once_with( + "ov-example", + user_id="alice", + role="user", + page=2, + limit=10, + ) + + def test_user_update_requires_regenerate_key(self): + result = self.runner.invoke( + app, + [ + "--api-key", + "ark-test", + "user", + "update", + "ov-example", + "alice", + ], + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn("nothing to update", result.output) + + +class UserMcpContractTest(unittest.TestCase): + def test_api_key_tool_forwards_user_id(self): + with patch.object( + server, + "get_client", + ) as get_client: + get_client.return_value.get_user_access.return_value = { + "UserID": "alice", + "Role": "user", + "ApiKey": "plain", + } + + result = server.get_collection_api_key("ov-example", user_id="alice") + + self.assertEqual(result["UserID"], "alice") + get_client.return_value.get_user_access.assert_called_once_with( + "ov-example", + user_id="alice", + ) + + def test_list_users_tool_forwards_filters_and_pagination(self): + with patch.object(server, "get_client") as get_client: + get_client.return_value.list_collection_users.return_value = { + "UserList": [], + "Total": 0, + } + + server.list_collection_users( + "ov-example", + user_id="alice", + role="user", + page=2, + limit=10, + ) + + get_client.return_value.list_collection_users.assert_called_once_with( + "ov-example", + user_id="alice", + role="user", + page=2, + limit=10, + ) + + def test_register_and_update_tools_match_backend_fields(self): + with patch.object(server, "get_client") as get_client: + get_client.return_value.register_user.return_value = {"Success": True} + get_client.return_value.update_user.return_value = {"Success": True} + + server.register_collection_user("ov-example", "alice") + server.update_collection_user( + "ov-example", + "alice", + regenerate_key=True, + ) + + get_client.return_value.register_user.assert_called_once_with( + "ov-example", + "alice", + ) + get_client.return_value.update_user.assert_called_once_with( + "ov-example", + "alice", + regenerate_key=True, + ) + + +if __name__ == "__main__": + unittest.main() From 1bba701c8c4ca514f100a38cd5ba9df5800ce42e Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 31 Jul 2026 16:49:04 +0800 Subject: [PATCH 11/20] fix(openviking-controlplane): preserve model credentials on update Send VLM and Embedding blocks only when explicitly supplied so description and billing updates cannot overwrite existing multi-credential configuration. Co-authored-by: TRAE CLI --- .../client.py | 24 ++--- .../server.py | 8 +- .../tests/test_collection_update.py | 87 +++++++++++++++++++ 3 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_collection_update.py diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 90d7f617..d08b005b 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -308,18 +308,20 @@ def update_collection( pay_type / seat_id, validated by ``build_payment_config``. Omitting both leaves the current billing untouched. - NOTE: the backend re-validates model credentials on every update, so VLM and - Embedding blocks are always sent (built like ``create_collection`` — for - ``source == "agentplan"`` the model credential falls back to the configured - AgentPlan key). Passing an empty/whitespace Description is a server-side no-op - (the field is only overwritten by a non-empty value). ``extra`` is merged - verbatim for forward-compatibility.""" + VLM and Embedding are sent only when explicitly supplied. This preserves + existing multi-credential model configuration during description or billing + updates. Passing an empty/whitespace Description is a server-side no-op. + ``extra`` is merged verbatim for forward-compatibility.""" payment = build_payment_config(pay_type, seat_id) - body: Dict[str, Any] = { - "ResourceID": resource_id, - "VLM": self._model_block(vlm, source, DEFAULT_VLM_MODEL), - "Embedding": self._model_block(embedding, source, DEFAULT_EMBEDDING_MODEL), - } + body: Dict[str, Any] = {"ResourceID": resource_id} + if vlm is not None: + body["VLM"] = self._model_block(vlm, source, DEFAULT_VLM_MODEL) + if embedding is not None: + body["Embedding"] = self._model_block( + embedding, + source, + DEFAULT_EMBEDDING_MODEL, + ) if payment is not None: body["PaymentConfig"] = payment if description is not None: diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 5317860a..ddf33f84 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -203,10 +203,10 @@ def update_collection( THE USER before calling — this mutates a live library. This is also the way to SWITCH BILLING (volc_pay ↔ AgentPlan deduction, or re-bind a seat after it was unbound); omitting both pay_type and seat_id leaves billing untouched. - The backend re-validates model credentials on update, so VLM/Embedding are - sent automatically using the configured AgentPlan key. NOTE: an - empty/whitespace description is a server-side no-op — the description can - only be overwritten with a non-empty value. + Model configuration is not sent by this tool, so description and billing + changes preserve existing VLM/Embedding credentials. NOTE: an empty/whitespace + description is a server-side no-op — the description can only be overwritten + with a non-empty value. Args: resource_id: target library ResourceID. diff --git a/server/mcp_server_openviking_controlplane/tests/test_collection_update.py b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py new file mode 100644 index 00000000..65a6e004 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py @@ -0,0 +1,87 @@ +import unittest +from unittest.mock import patch + +from mcp_server_openviking_controlplane.client import ControlPlaneClient +from mcp_server_openviking_controlplane.config import ControlPlaneConfig + + +class CollectionUpdateContractTest(unittest.TestCase): + def setUp(self): + self.client = ControlPlaneClient(ControlPlaneConfig(api_key="ark-test")) + + def test_description_update_preserves_model_configuration(self): + with patch.object( + self.client, + "_request", + return_value={"Success": True}, + ) as request: + self.client.update_collection( + "ov-example", + description="new description", + ) + + request.assert_called_once_with( + "UpdateOpenVikingCollection", + { + "ResourceID": "ov-example", + "Description": "new description", + }, + ) + + def test_billing_update_preserves_model_configuration(self): + with patch.object( + self.client, + "_request", + return_value={"Success": True}, + ) as request: + self.client.update_collection( + "ov-example", + pay_type="volc_pay", + ) + + request.assert_called_once_with( + "UpdateOpenVikingCollection", + { + "ResourceID": "ov-example", + "PaymentConfig": {"PayType": "volc_pay"}, + }, + ) + + def test_explicit_model_updates_are_forwarded(self): + with patch.object( + self.client, + "_request", + return_value={"Success": True}, + ) as request: + self.client.update_collection( + "ov-example", + vlm={ + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [ + { + "Source": "agentplan", + "ApiKey": "ark-model", + } + ], + }, + ) + + request.assert_called_once_with( + "UpdateOpenVikingCollection", + { + "ResourceID": "ov-example", + "VLM": { + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [ + { + "Source": "agentplan", + "ApiKey": "ark-model", + } + ], + }, + }, + ) + + +if __name__ == "__main__": + unittest.main() From 94f50ed1a69fe52a1523affca5952452a6c06f71 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 31 Jul 2026 16:53:36 +0800 Subject: [PATCH 12/20] fix(openviking-controlplane): remove unsupported version updates Drop the update-only OpenViking image version parameter because the current backend silently ignores it; creation-time version selection remains supported. Co-authored-by: TRAE CLI --- .../src/mcp_server_openviking_controlplane/cli.py | 2 -- .../src/mcp_server_openviking_controlplane/client.py | 3 --- .../src/mcp_server_openviking_controlplane/server.py | 3 --- .../tests/test_collection_update.py | 8 ++++++++ 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 1ca521e1..6154d16b 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -279,7 +279,6 @@ def update_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), description: Optional[str] = typer.Option(None, help="New description, <=65535 chars."), - openviking_version: Optional[str] = typer.Option(None, help="New image version."), pay_type: Optional[PayTypeOption] = typer.Option( None, "--pay-type", help="Switch billing: agentplan_personal (personal AgentPlan AFP) | " @@ -307,7 +306,6 @@ def update_cmd( client.update_collection( resource_id, description=description, - openviking_version=openviking_version, pay_type=pay_type.value if pay_type else None, seat_id=seat_id, ), diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index d08b005b..9b621cef 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -296,7 +296,6 @@ def update_collection( source: str = "agentplan", vlm: Optional[Dict[str, Any]] = None, embedding: Optional[Dict[str, Any]] = None, - openviking_version: Optional[str] = None, pay_type: Optional[str] = None, seat_id: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, @@ -326,8 +325,6 @@ def update_collection( body["PaymentConfig"] = payment if description is not None: body["Description"] = description - if openviking_version is not None: - body["OpenvikingVersion"] = openviking_version if extra: body.update(extra) return self._request("UpdateOpenVikingCollection", body) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index ddf33f84..491dc019 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -193,7 +193,6 @@ def create_collection( def update_collection( resource_id: str, description: Optional[str] = None, - openviking_version: Optional[str] = None, pay_type: Optional[str] = None, seat_id: Optional[str] = None, ) -> Dict[str, Any]: @@ -211,7 +210,6 @@ def update_collection( Args: resource_id: target library ResourceID. description: new description, length <= 65535 (non-empty to take effect). - openviking_version: new image version. pay_type: new billing — "agentplan_personal" (personal AgentPlan AFP), "agentplan_enterprise" (an enterprise seat's AFP; requires seat_id), or "volc_pay" (Volcano pay-as-you-go, billed to the @@ -229,7 +227,6 @@ def update_collection( return get_client().update_collection( resource_id, description=description, - openviking_version=openviking_version, pay_type=pay_type, seat_id=seat_id, ) diff --git a/server/mcp_server_openviking_controlplane/tests/test_collection_update.py b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py index 65a6e004..c95fd29c 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_collection_update.py +++ b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py @@ -1,6 +1,9 @@ import unittest +from inspect import signature from unittest.mock import patch +from mcp_server_openviking_controlplane import server +from mcp_server_openviking_controlplane.cli import update_cmd from mcp_server_openviking_controlplane.client import ControlPlaneClient from mcp_server_openviking_controlplane.config import ControlPlaneConfig @@ -82,6 +85,11 @@ def test_explicit_model_updates_are_forwarded(self): }, ) + def test_removed_version_update_is_not_exposed(self): + self.assertNotIn("openviking_version", signature(self.client.update_collection).parameters) + self.assertNotIn("openviking_version", signature(server.update_collection).parameters) + self.assertNotIn("openviking_version", signature(update_cmd).parameters) + if __name__ == "__main__": unittest.main() From 1829be78bad59684ca26237bc81e252cb270bd70 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Thu, 20 Aug 2026 14:10:52 +0800 Subject: [PATCH 13/20] fix(openviking-controlplane): simplify collection creation Co-authored-by: TRAE CLI --- .../README.md | 4 +- .../README_zh.md | 4 +- .../skills/openviking-controlplane/SKILL.md | 10 +- .../mcp_server_openviking_controlplane/cli.py | 44 +------- .../server.py | 20 +--- .../tests/test_collection_create.py | 104 ++++++++++++++++++ 6 files changed, 120 insertions(+), 66 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_collection_create.py diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 21ee9280..7d53becd 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -85,8 +85,8 @@ uv run ov-cp usage uv run ov-cp api-key uv run ov-cp api-key --user-id xiaohong -# create (consumes paid quota; with source=agentplan only --name is needed — -# model names default, and the model ApiKey falls back to the configured key) +# create (consumes paid quota; always uses the AgentPlan model path and the +# configured AgentPlan key; model source/parameters and image version are hidden) uv run ov-cp create --name my_kb # create an enterprise-tier library (higher capacity, enterprise billing rates) diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index a8e2dd09..0104bd18 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -79,8 +79,8 @@ uv run ov-cp usage uv run ov-cp api-key uv run ov-cp api-key --user-id xiaohong -# 建库(消耗付费配额;source=agentplan 时只需 --name, -# 模型名取默认、模型 ApiKey 回落到配置的 key) +# 建库(消耗付费配额;固定使用 AgentPlan 模型路径和已配置的 AgentPlan key, +# 不开放模型来源、模型参数、模型鉴权与 OpenViking 镜像版本) uv run ov-cp create --name my_kb # 建企业版库(容量更高,按企业版费率计费) diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 7dd09819..25f39dee 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -72,18 +72,14 @@ collections also include the AFP amount and business scenario. ⚠️ **Billable + requires the account to have AgentPlan deduction activated** (else `ProductUnordered`). Confirm with the user before creating. Max 20 libraries/account. -For `--source agentplan` (default) you only need `--name`: the VLM/Embedding model -names default to `doubao-seed-2.0-lite` / `doubao-embedding-vision`, and the model -ApiKey falls back to the configured AgentPlan key. +The public create command always uses the AgentPlan model path and the configured +AgentPlan key. It does not expose model source, model parameters, model credentials, +or an OpenViking image-version override. ```bash ov-cp create --name my_kb # enterprise tier (higher capacity, enterprise billing rates): ov-cp create --name my_kb --version enterprise -# other sources need explicit model creds: -ov-cp create --name my_kb --source volcengine \ - --vlm-api-key-id --vlm-endpoint-id \ - --emb-api-key-id --emb-endpoint-id ``` `--version` is `developer` (default) or `enterprise`; any other value is rejected diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index 6154d16b..cb0ae44c 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -7,8 +7,6 @@ from mcp_server_openviking_controlplane.client import ControlPlaneClient, ControlPlaneError from mcp_server_openviking_controlplane.config import ( - DEFAULT_EMBEDDING_MODEL, - DEFAULT_VLM_MODEL, build_config, parse_extra_headers, ) @@ -68,26 +66,6 @@ def _client(ctx: typer.Context) -> ControlPlaneClient: raise _fail(e) -def _model_cfg( - model_name: str, - api_key_id: Optional[str], - api_key: Optional[str], - endpoint_id: Optional[str], -) -> Dict[str, Any]: - """Assemble a VLM/Embedding model config from whatever the caller supplied. - - No key is required here: for the ``agentplan`` source the client fills in the - configured AgentPlan ApiKey; other sources are validated server-side.""" - cfg: Dict[str, Any] = {"ModelName": model_name} - if api_key_id: - cfg["ApiKeyID"] = api_key_id - if api_key: - cfg["ApiKey"] = api_key - if endpoint_id: - cfg["EndpointID"] = endpoint_id - return cfg - - @app.callback() def main_callback( ctx: typer.Context, @@ -195,23 +173,13 @@ def api_key_cmd( def create_cmd( ctx: typer.Context, name: str = typer.Option(..., help="Library name ^[a-zA-Z][a-zA-Z0-9_]*$, <=64."), - source: str = typer.Option("agentplan", help="Model source: agentplan | volcengine | codeplan."), version: VersionOption = typer.Option( VersionOption.DEVELOPER, help="Library tier: developer (default) | enterprise " "(higher capacity, billed at enterprise rates).", ), - vlm_model: str = typer.Option(DEFAULT_VLM_MODEL, help="VLM ModelName."), - vlm_api_key_id: Optional[str] = typer.Option(None, help="VLM ApiKeyID (exclusive with --vlm-api-key)."), - vlm_api_key: Optional[str] = typer.Option(None, help="VLM ApiKey (defaults to --api-key when source=agentplan)."), - vlm_endpoint_id: Optional[str] = typer.Option(None, help="VLM EndpointID (volcengine source only)."), - emb_model: str = typer.Option(DEFAULT_EMBEDDING_MODEL, help="Embedding ModelName."), - emb_api_key_id: Optional[str] = typer.Option(None, help="Embedding ApiKeyID (exclusive with --emb-api-key)."), - emb_api_key: Optional[str] = typer.Option(None, help="Embedding ApiKey (defaults to --api-key when source=agentplan)."), - emb_endpoint_id: Optional[str] = typer.Option(None, help="Embedding EndpointID (volcengine source only)."), project: Optional[str] = typer.Option(None, help="Project name (defaults to configured)."), description: Optional[str] = typer.Option(None, help="Description, <=65535 chars."), - openviking_version: Optional[str] = typer.Option(None, help="Image version (optional)."), pay_type: Optional[PayTypeOption] = typer.Option( None, "--pay-type", help="Billing: agentplan_personal (personal AgentPlan AFP deduction; the " @@ -233,8 +201,9 @@ def create_cmd( ): """Create a new collection (consumes paid quota; max 20 per account). - For source=agentplan you can pass just --name: the model names default to the - AgentPlan models and the model ApiKey falls back to --api-key / AGENTPLAN_API_KEY. + Model source, model parameters, model credentials, and the OpenViking image + version are not configurable here. Creation always uses the AgentPlan model + path and the configured AgentPlan API key. ⚠️ Billing: without --pay-type the library defaults to agentplan_personal (AFP deduction from the account's personal AgentPlan). Enterprise seat @@ -251,20 +220,15 @@ def create_cmd( "--seat-id ... (or volc_pay) instead.", err=True, ) - vlm = _model_cfg(vlm_model, vlm_api_key_id, vlm_api_key, vlm_endpoint_id) - embedding = _model_cfg(emb_model, emb_api_key_id, emb_api_key, emb_endpoint_id) try: _print( ctx, client.create_collection( name=name, - source=source, - vlm=vlm, - embedding=embedding, + source="agentplan", version=version.value, project=project, description=description, - openviking_version=openviking_version, pay_type=pay_type.value if pay_type else None, seat_id=seat_id, ), diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 491dc019..5888e105 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -114,13 +114,9 @@ def get_collection_api_key( @mcp.tool() def create_collection( name: str, - vlm: Optional[Dict[str, Any]] = None, - embedding: Optional[Dict[str, Any]] = None, - source: str = "agentplan", version: str = "developer", project: Optional[str] = None, description: Optional[str] = None, - openviking_version: Optional[str] = None, pay_type: Optional[str] = None, seat_id: Optional[str] = None, ) -> Dict[str, Any]: @@ -141,20 +137,17 @@ def create_collection( personal plan, deduction fails and the library is disabled. Confirm the intended billing with the user before creating. + The public tool always uses the AgentPlan model path. Model source, model + parameters, model credentials, and the OpenViking image version are intentionally + not configurable here. + Args: name: library name, regex ^[a-zA-Z][a-zA-Z0-9_]*$, length <= 64. - vlm: optional VLM model config, e.g. {"ModelName": "...", "ApiKeyID": "..."}. - For source="agentplan" this can be omitted — the model name defaults - to the AgentPlan VLM and the ApiKey falls back to the configured - AgentPlan key. ApiKeyID and ApiKey are mutually exclusive. - embedding: optional embedding model config, same shape/defaults as vlm. - source: model source — "agentplan" (default), "volcengine", or "codeplan". version: library tier — "developer" (default) or "enterprise". Sets the RATE only (enterprise: 25 AFP baseline / 200k files, then tiered per 100k files beyond); billing SOURCE is pay_type, orthogonal. project: project name; defaults to the configured project. description: optional, length <= 65535. - openviking_version: optional image version. pay_type: how the library is billed — "agentplan_personal" (personal AgentPlan AFP deduction; the default when omitted), "agentplan_enterprise" (an enterprise seat's AFP pays; @@ -174,13 +167,10 @@ def create_collection( try: return get_client().create_collection( name=name, - source=source, - vlm=vlm, - embedding=embedding, + source="agentplan", version=version, project=project, description=description, - openviking_version=openviking_version, pay_type=pay_type, seat_id=seat_id, ) diff --git a/server/mcp_server_openviking_controlplane/tests/test_collection_create.py b/server/mcp_server_openviking_controlplane/tests/test_collection_create.py new file mode 100644 index 00000000..e13bf985 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_collection_create.py @@ -0,0 +1,104 @@ +import unittest +from inspect import signature +from unittest.mock import patch + +from typer.testing import CliRunner + +from mcp_server_openviking_controlplane import server +from mcp_server_openviking_controlplane.cli import app, create_cmd +from mcp_server_openviking_controlplane.client import ControlPlaneClient + + +class CollectionCreateContractTest(unittest.TestCase): + def test_advanced_model_options_are_only_available_in_low_level_client(self): + low_level_parameters = signature(ControlPlaneClient.create_collection).parameters + for name in ("source", "vlm", "embedding", "openviking_version"): + self.assertIn(name, low_level_parameters) + self.assertNotIn(name, signature(create_cmd).parameters) + self.assertNotIn(name, signature(server.create_collection).parameters) + + +class CollectionCreateCliTest(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + + def test_help_hides_model_and_image_options(self): + result = self.runner.invoke(app, ["create", "--help"]) + + self.assertEqual(result.exit_code, 0) + for option in ( + "--source", + "--vlm-model", + "--vlm-api-key-id", + "--vlm-api-key", + "--vlm-endpoint-id", + "--emb-model", + "--emb-api-key-id", + "--emb-api-key", + "--emb-endpoint-id", + "--openviking-version", + ): + self.assertNotIn(option, result.output) + self.assertIn("--version", result.output) + + def test_create_uses_agentplan_without_model_arguments(self): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.create_collection", + return_value={"ResourceID": "ov-example", "Success": True}, + ) as create_collection: + result = self.runner.invoke( + app, + [ + "--api-key", + "ark-test", + "--json", + "create", + "--name", + "demo", + "--version", + "enterprise", + "--pay-type", + "volc_pay", + ], + ) + + self.assertEqual(result.exit_code, 0) + create_collection.assert_called_once_with( + name="demo", + source="agentplan", + version="enterprise", + project=None, + description=None, + pay_type="volc_pay", + seat_id=None, + ) + + +class CollectionCreateMcpTest(unittest.TestCase): + def test_create_uses_agentplan_without_model_arguments(self): + with patch.object(server, "get_client") as get_client: + get_client.return_value.create_collection.return_value = { + "ResourceID": "ov-example", + "Success": True, + } + + result = server.create_collection( + "demo", + version="enterprise", + pay_type="volc_pay", + ) + + self.assertTrue(result["Success"]) + get_client.return_value.create_collection.assert_called_once_with( + name="demo", + source="agentplan", + version="enterprise", + project=None, + description=None, + pay_type="volc_pay", + seat_id=None, + ) + + +if __name__ == "__main__": + unittest.main() From e8434f4060ce9a6dac783810dbdcf2ee1fe1ef0d Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Thu, 20 Aug 2026 17:24:57 +0800 Subject: [PATCH 14/20] fix(openviking-controlplane): replay collection credentials on update A control plane that rebuilds VLM and Embedding on every update rejects a metadata-only request with "apikey is empty", because it rebuilds from the legacy flat ApiKey, which is blank once a collection stores an N-credential list. Read the collection's own credentials back and replay them once, then retry: credentials with an ApiKeyID are resolved server-side, and the AgentPlan credential reuses the control-plane key the client authenticates with, exactly as create_collection builds it. Anything else is refused instead of guessed at, and the retry is reported in the response Note. --- .../README.md | 4 + .../README_zh.md | 3 + .../skills/openviking-controlplane/SKILL.md | 7 + .../client.py | 110 +++++++++++++- .../tests/test_collection_update.py | 135 +++++++++++++++++- 5 files changed, 256 insertions(+), 3 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 7d53becd..c0f9c9ad 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -111,6 +111,10 @@ uv run ov-cp create --name my_kb --version enterprise \ # also switches billing (volc_pay <-> AgentPlan, or re-bind a seat) uv run ov-cp update --description "new description" uv run ov-cp update --pay-type volc_pay +# Model configuration is never sent by these commands. If the control plane +# still rebuilds both models on every update, `update` replays the library's +# own credentials once and says so in the response Note; a credential with no +# ApiKeyID (other than AgentPlan) cannot be replayed and the update is refused. # manage users of an enterprise-tier library (key must be associated with it) uv run ov-cp user list diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 0104bd18..90af1452 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -101,6 +101,9 @@ uv run ov-cp create --name my_kb --version enterprise \ # 更新库可变字段(只改传入的字段);也用于切换计费方式 / 换绑席位 uv run ov-cp update --description "新描述" uv run ov-cp update --pay-type volc_pay +# 这些命令不会发送模型配置。若控制面仍在每次更新时重建模型,`update` 会读回该库 +# 自身的凭证重放一次,并在响应 Note 中说明;除 AgentPlan 外,没有 ApiKeyID 的 +# 凭证无法重放,此时更新会被拒绝而不是猜测。 # 管理企业版库的用户(key 需与该库已关联) uv run ov-cp user list diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 25f39dee..3bfbc814 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -56,6 +56,13 @@ After `user update --regenerate-key`, fetch the replacement with `api-key --user-id `; the update response only confirms success and does not contain the new key. +`update` never sends model configuration. Against a control plane that still +rebuilds both models on every update (it rejects a metadata-only request with +`apikey is empty`), `update` reads the library's own credentials back and +replays them once, reporting it in the response `Note`. A non-AgentPlan +credential stored without an ApiKeyID cannot be replayed — the update is +refused rather than guessed at. + In a terminal, output defaults to structured Rich views. Pipes and redirects automatically receive standard JSON, so `ov-cp list | jq ...` and command substitution remain safe. Use the global `--json`, `--output json-compact`, or diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 9b621cef..22ce4f4b 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -21,6 +21,9 @@ # breaks the request. We always send a freshly serialized JSON body. _DROP_HEADERS = {"content-length", "connection", "accept-encoding"} _AFP_PER_CNY = Decimal("500") +# A control plane that still rebuilds both model configs on every update +# rejects a metadata-only request with this message; see update_collection. +_MODEL_REPLAY_ERROR = "apikey is empty" def _format_decimal(value: Decimal) -> str: @@ -310,7 +313,13 @@ def update_collection( VLM and Embedding are sent only when explicitly supplied. This preserves existing multi-credential model configuration during description or billing updates. Passing an empty/whitespace Description is a server-side no-op. - ``extra`` is merged verbatim for forward-compatibility.""" + ``extra`` is merged verbatim for forward-compatibility. + + A control plane that still rebuilds both models on every update rejects + such a metadata-only request with "apikey is empty": it rebuilds from the + legacy flat ApiKey, which is blank once a collection stores an + N-credential list. We then replay the collection's own credentials once + and retry — see ``_replay_model_blocks``.""" payment = build_payment_config(pay_type, seat_id) body: Dict[str, Any] = {"ResourceID": resource_id} if vlm is not None: @@ -327,7 +336,104 @@ def update_collection( body["Description"] = description if extra: body.update(extra) - return self._request("UpdateOpenVikingCollection", body) + try: + return self._request("UpdateOpenVikingCollection", body) + except ControlPlaneError as exc: + if "VLM" in body or "Embedding" in body: + raise # the caller's own model credentials were rejected + if _MODEL_REPLAY_ERROR not in exc.message.lower(): + raise + logger.warning( + "control plane rejected a metadata-only update with %r; " + "replaying the collection's existing model credentials", + exc.message, + ) + blocks = self._replay_model_blocks(resource_id) + body.update(blocks) + result = self._request("UpdateOpenVikingCollection", body) + if isinstance(result, dict): + result["Note"] = self._replay_note(blocks) + return result + + def _replay_model_blocks(self, resource_id: str) -> Dict[str, Any]: + """Rebuild VLM/Embedding request blocks from the collection's own config. + + Used only to work around a control plane that rebuilds both models even + for a metadata-only update. The Get response masks every ApiKey, so a + credential can be replayed only through its ApiKeyID — except the + AgentPlan one, whose model key is the control-plane key we already + authenticate with (exactly how ``create_collection`` builds it).""" + collection = self.get_collection(resource_id) + blocks: Dict[str, Any] = {} + for label, default_model in ( + ("VLM", DEFAULT_VLM_MODEL), + ("Embedding", DEFAULT_EMBEDDING_MODEL), + ): + config = collection.get(label) + credentials = config.get("Credentials") if isinstance(config, dict) else None + if not credentials: + raise ControlPlaneError( + "CredentialNotReplayable", + f"{label} has no credentials to replay; pass the model " + f"configuration explicitly to update this collection.", + ) + blocks[label] = { + "ModelName": config.get("ModelName") or default_model, + "Credentials": [ + self._replay_credential(cred, label) for cred in credentials + ], + } + return blocks + + def _replay_credential(self, cred: Dict[str, Any], label: str) -> Dict[str, Any]: + """Rebuild one request credential from a masked Get response entry.""" + source = str(cred.get("Source") or "").strip() + replayed: Dict[str, Any] = {"Source": source} + provider = str(cred.get("Provider") or "").strip() + if provider: + replayed["Provider"] = provider + + api_key_id = str(cred.get("ApiKeyID") or "").strip() + if api_key_id: # server-side lookup; the plaintext key never reaches us + replayed["ApiKeyID"] = api_key_id + elif source == "agentplan": + replayed["ApiKey"] = self.config.api_key + else: + raise ControlPlaneError( + "CredentialNotReplayable", + f"{label} credential {source!r} carries no ApiKeyID and its " + f"ApiKey is masked in the Get response; pass the model " + f"configuration explicitly to update this collection.", + ) + + endpoint_id = str(cred.get("EndpointID") or "").strip() + if source == "volcengine": # required by the backend for this source only + if not endpoint_id: + raise ControlPlaneError( + "CredentialNotReplayable", + f"{label} volcengine credential has no EndpointID to replay.", + ) + replayed["EndpointID"] = endpoint_id + return replayed + + @staticmethod + def _replay_note(blocks: Dict[str, Any]) -> str: + """Explain the replay in the result, since it rewrites stored credentials.""" + note = ( + "The control plane rebuilt both model configurations on this update, " + "so the collection's existing credentials were replayed." + ) + rewritten = any( + "ApiKey" in cred + for block in blocks.values() + for cred in block["Credentials"] + ) + if rewritten: + note += ( + " The AgentPlan model credential was re-set to the key this " + "client authenticates with." + ) + return note def delete_collection(self, resource_id: str) -> Dict[str, Any]: return self._request("DeleteOpenVikingCollection", {"ResourceID": resource_id}) diff --git a/server/mcp_server_openviking_controlplane/tests/test_collection_update.py b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py index c95fd29c..35f2ae4b 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_collection_update.py +++ b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py @@ -4,7 +4,10 @@ from mcp_server_openviking_controlplane import server from mcp_server_openviking_controlplane.cli import update_cmd -from mcp_server_openviking_controlplane.client import ControlPlaneClient +from mcp_server_openviking_controlplane.client import ( + ControlPlaneClient, + ControlPlaneError, +) from mcp_server_openviking_controlplane.config import ControlPlaneConfig @@ -85,6 +88,136 @@ def test_explicit_model_updates_are_forwarded(self): }, ) + def test_replays_existing_credentials_when_backend_rebuilds_models(self): + collection = { + "VLM": { + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [ + {"Source": "agentplan", "Provider": "volcengine"}, + { + "Source": "volcengine", + "Provider": "volcengine", + "ApiKeyID": "4131627", + "EndpointID": "ep-vlm", + }, + ], + }, + "Embedding": { + "ModelName": "doubao-embedding-vision", + "Credentials": [ + { + "Source": "volcengine", + "Provider": "volcengine", + "ApiKeyID": "4131628", + "EndpointID": "ep-embedding", + } + ], + }, + } + rejection = ControlPlaneError("InvalidParameter", "apikey is empty") + + def respond(action, body): + if action == "GetOpenVikingCollection": + return collection + if "VLM" not in body: + raise rejection + return {"Success": True} + + with patch.object(self.client, "_request", side_effect=respond) as request: + result = self.client.update_collection( + "ov-example", + description="new description", + ) + + self.assertEqual( + [call.args[0] for call in request.call_args_list], + [ + "UpdateOpenVikingCollection", + "GetOpenVikingCollection", + "UpdateOpenVikingCollection", + ], + ) + self.assertEqual( + request.call_args_list[-1].args[1], + { + "ResourceID": "ov-example", + "Description": "new description", + "VLM": { + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [ + { + "Source": "agentplan", + "Provider": "volcengine", + "ApiKey": "ark-test", + }, + { + "Source": "volcengine", + "Provider": "volcengine", + "ApiKeyID": "4131627", + "EndpointID": "ep-vlm", + }, + ], + }, + "Embedding": { + "ModelName": "doubao-embedding-vision", + "Credentials": [ + { + "Source": "volcengine", + "Provider": "volcengine", + "ApiKeyID": "4131628", + "EndpointID": "ep-embedding", + } + ], + }, + }, + ) + self.assertIn("AgentPlan", result["Note"]) + + def test_replay_is_refused_when_a_credential_cannot_be_rebuilt(self): + collection = { + "VLM": { + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [{"Source": "codeplan", "Provider": "volcengine"}], + }, + } + + def respond(action, body): + if action == "GetOpenVikingCollection": + return collection + raise ControlPlaneError("InvalidParameter", "apikey is empty") + + with patch.object(self.client, "_request", side_effect=respond): + with self.assertRaises(ControlPlaneError) as raised: + self.client.update_collection("ov-example", description="new") + + self.assertEqual(raised.exception.code, "CredentialNotReplayable") + self.assertIn("codeplan", raised.exception.message) + + def test_explicit_model_update_failure_is_not_retried(self): + with patch.object( + self.client, + "_request", + side_effect=ControlPlaneError("InvalidParameter", "apikey is empty"), + ) as request: + with self.assertRaises(ControlPlaneError): + self.client.update_collection( + "ov-example", + vlm={"Credentials": [{"Source": "agentplan", "ApiKey": "ark-x"}]}, + ) + + self.assertEqual(request.call_count, 1) + + def test_unrelated_errors_are_not_retried(self): + with patch.object( + self.client, + "_request", + side_effect=ControlPlaneError("ProductUnordered", "product not ordered"), + ) as request: + with self.assertRaises(ControlPlaneError): + self.client.update_collection("ov-example", description="new") + + self.assertEqual(request.call_count, 1) + def test_removed_version_update_is_not_exposed(self): self.assertNotIn("openviking_version", signature(self.client.update_collection).parameters) self.assertNotIn("openviking_version", signature(server.update_collection).parameters) From 58c0fc3efdcb4954ddc424c1091c501ed3d9bc4a Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Thu, 20 Aug 2026 19:42:57 +0800 Subject: [PATCH 15/20] feat(openviking-controlplane): allow overwriting the model key on update `update --model-api-key` (MCP: model_api_key) writes the supplied AgentPlan key as the model credential of both VLM and Embedding, which always share one. The collection's remaining credentials are read back and replayed unchanged, so overwriting the AgentPlan key no longer costs the volcengine failover entry. A collection with no AgentPlan model credential is refused rather than reshaped, and the flag cannot be combined with an explicit vlm / embedding block. --- .../README.md | 5 +- .../README_zh.md | 9 +- .../skills/openviking-controlplane/SKILL.md | 19 ++- .../mcp_server_openviking_controlplane/cli.py | 8 ++ .../client.py | 81 ++++++++--- .../server.py | 17 ++- .../tests/test_collection_update.py | 133 ++++++++++++++++++ 7 files changed, 240 insertions(+), 32 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index c0f9c9ad..124cec7a 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -111,7 +111,10 @@ uv run ov-cp create --name my_kb --version enterprise \ # also switches billing (volc_pay <-> AgentPlan, or re-bind a seat) uv run ov-cp update --description "new description" uv run ov-cp update --pay-type volc_pay -# Model configuration is never sent by these commands. If the control plane +# overwrite the library's AgentPlan MODEL credential (VLM and Embedding share it); +# the library's other credentials are replayed unchanged +uv run ov-cp update --model-api-key ark-xxxxxxxx +# Without --model-api-key no model configuration is sent. If the control plane # still rebuilds both models on every update, `update` replays the library's # own credentials once and says so in the response Note; a credential with no # ApiKeyID (other than AgentPlan) cannot be replayed and the update is refused. diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 90af1452..7406614c 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -101,9 +101,12 @@ uv run ov-cp create --name my_kb --version enterprise \ # 更新库可变字段(只改传入的字段);也用于切换计费方式 / 换绑席位 uv run ov-cp update --description "新描述" uv run ov-cp update --pay-type volc_pay -# 这些命令不会发送模型配置。若控制面仍在每次更新时重建模型,`update` 会读回该库 -# 自身的凭证重放一次,并在响应 Note 中说明;除 AgentPlan 外,没有 ApiKeyID 的 -# 凭证无法重放,此时更新会被拒绝而不是猜测。 +# 覆盖该库的 AgentPlan 模型凭证(VLM 与 Embedding 共用同一把 key), +# 库里其它凭证按原样重放,不受影响 +uv run ov-cp update --model-api-key ark-xxxxxxxx +# 不带 --model-api-key 时不会发送任何模型配置。若控制面仍在每次更新时重建模型, +# `update` 会读回该库自身的凭证重放一次,并在响应 Note 中说明;除 AgentPlan 外, +# 没有 ApiKeyID 的凭证无法重放,此时更新会被拒绝而不是猜测。 # 管理企业版库的用户(key 需与该库已关联) uv run ov-cp user list diff --git a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index 3bfbc814..fb47349d 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -42,6 +42,7 @@ ov-cp api-key # default user's plaintext data-plane key ov-cp api-key --user-id xiaohong # selected user's plaintext key ov-cp create --name my_kb # create a collection (see below) ov-cp update --description "..." # update fields / switch billing +ov-cp update --model-api-key ark-xxx # overwrite AgentPlan model key ov-cp delete --yes # delete (irreversible; uninstalls the Helm release) # users of an enterprise-tier library (key must be associated with the library): @@ -56,12 +57,18 @@ After `user update --regenerate-key`, fetch the replacement with `api-key --user-id `; the update response only confirms success and does not contain the new key. -`update` never sends model configuration. Against a control plane that still -rebuilds both models on every update (it rejects a metadata-only request with -`apikey is empty`), `update` reads the library's own credentials back and -replays them once, reporting it in the response `Note`. A non-AgentPlan -credential stored without an ApiKeyID cannot be replayed — the update is -refused rather than guessed at. +`update --model-api-key ` overwrites the library's AgentPlan MODEL +credential; VLM and Embedding always share one key, and the library's other +credentials are replayed unchanged. Only pass a key the user gave you for this +purpose — it REPLACES what is stored. A library with no AgentPlan model +credential is refused rather than reshaped. + +Without that flag `update` sends no model configuration at all. Against a +control plane that still rebuilds both models on every update (it rejects a +metadata-only request with `apikey is empty`), `update` reads the library's own +credentials back and replays them once, reporting it in the response `Note`. A +non-AgentPlan credential stored without an ApiKeyID cannot be replayed — the +update is refused rather than guessed at. In a terminal, output defaults to structured Rich views. Pipes and redirects automatically receive standard JSON, so `ov-cp list | jq ...` and command diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py index cb0ae44c..a481afbc 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/cli.py @@ -256,6 +256,13 @@ def update_cmd( "agentplan_enterprise (also how to re-bind after a seat was " "unbound). The server does NOT check the seat exists.", ), + model_api_key: Optional[str] = typer.Option( + None, "--model-api-key", + help="Overwrite the library's AgentPlan MODEL credential with this key " + "(VLM and Embedding always share one). Omit to leave model " + "credentials alone; the library's other credentials are kept " + "either way.", + ), ): """Update mutable fields of a collection (only passed fields change). @@ -272,6 +279,7 @@ def update_cmd( description=description, pay_type=pay_type.value if pay_type else None, seat_id=seat_id, + model_api_key=model_api_key, ), "success", ) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index 22ce4f4b..f38b62b1 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -301,6 +301,7 @@ def update_collection( embedding: Optional[Dict[str, Any]] = None, pay_type: Optional[str] = None, seat_id: Optional[str] = None, + model_api_key: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Update a collection's mutable fields (e.g. Description, PaymentConfig). @@ -319,7 +320,13 @@ def update_collection( such a metadata-only request with "apikey is empty": it rebuilds from the legacy flat ApiKey, which is blank once a collection stores an N-credential list. We then replay the collection's own credentials once - and retry — see ``_replay_model_blocks``.""" + and retry — see ``_replay_model_blocks``. + + ``model_api_key`` overwrites the AgentPlan model credential of BOTH + models with the supplied key (they always share one). It is sent up + front rather than only on retry, since replacing a stored credential is + a deliberate act; the collection's other credentials are still replayed + untouched. Mutually exclusive with explicit vlm / embedding blocks.""" payment = build_payment_config(pay_type, seat_id) body: Dict[str, Any] = {"ResourceID": resource_id} if vlm is not None: @@ -336,6 +343,18 @@ def update_collection( body["Description"] = description if extra: body.update(extra) + if model_api_key: + if vlm is not None or embedding is not None: + raise ValueError( + "model_api_key cannot be combined with an explicit vlm / " + "embedding block; put the key in that block instead" + ) + blocks = self._replay_model_blocks(resource_id, model_api_key) + body.update(blocks) + result = self._request("UpdateOpenVikingCollection", body) + if isinstance(result, dict): + result["Note"] = self._replay_note(blocks, explicit_key=True) + return result try: return self._request("UpdateOpenVikingCollection", body) except ControlPlaneError as exc: @@ -352,16 +371,21 @@ def update_collection( body.update(blocks) result = self._request("UpdateOpenVikingCollection", body) if isinstance(result, dict): - result["Note"] = self._replay_note(blocks) + result["Note"] = self._replay_note(blocks, explicit_key=False) return result - def _replay_model_blocks(self, resource_id: str) -> Dict[str, Any]: + def _replay_model_blocks( + self, + resource_id: str, + agentplan_key: Optional[str] = None, + ) -> Dict[str, Any]: """Rebuild VLM/Embedding request blocks from the collection's own config. - Used only to work around a control plane that rebuilds both models even - for a metadata-only update. The Get response masks every ApiKey, so a - credential can be replayed only through its ApiKeyID — except the - AgentPlan one, whose model key is the control-plane key we already + Used to work around a control plane that rebuilds both models even for a + metadata-only update, and to carry an explicit AgentPlan model key. The + Get response masks every ApiKey, so a credential can be replayed only + through its ApiKeyID — except the AgentPlan one, whose model key is + ``agentplan_key`` or, by default, the control-plane key we already authenticate with (exactly how ``create_collection`` builds it).""" collection = self.get_collection(resource_id) blocks: Dict[str, Any] = {} @@ -380,12 +404,24 @@ def _replay_model_blocks(self, resource_id: str) -> Dict[str, Any]: blocks[label] = { "ModelName": config.get("ModelName") or default_model, "Credentials": [ - self._replay_credential(cred, label) for cred in credentials + self._replay_credential(cred, label, agentplan_key) + for cred in credentials ], } + if agentplan_key and not self._has_agentplan_credential(blocks): + raise ControlPlaneError( + "CredentialNotReplayable", + "this collection has no AgentPlan model credential to overwrite; " + "pass the model configuration explicitly instead.", + ) return blocks - def _replay_credential(self, cred: Dict[str, Any], label: str) -> Dict[str, Any]: + def _replay_credential( + self, + cred: Dict[str, Any], + label: str, + agentplan_key: Optional[str] = None, + ) -> Dict[str, Any]: """Rebuild one request credential from a masked Get response entry.""" source = str(cred.get("Source") or "").strip() replayed: Dict[str, Any] = {"Source": source} @@ -394,7 +430,9 @@ def _replay_credential(self, cred: Dict[str, Any], label: str) -> Dict[str, Any] replayed["Provider"] = provider api_key_id = str(cred.get("ApiKeyID") or "").strip() - if api_key_id: # server-side lookup; the plaintext key never reaches us + if agentplan_key and source == "agentplan": # explicit overwrite wins + replayed["ApiKey"] = agentplan_key + elif api_key_id: # server-side lookup; the plaintext key never reaches us replayed["ApiKeyID"] = api_key_id elif source == "agentplan": replayed["ApiKey"] = self.config.api_key @@ -417,18 +455,27 @@ def _replay_credential(self, cred: Dict[str, Any], label: str) -> Dict[str, Any] return replayed @staticmethod - def _replay_note(blocks: Dict[str, Any]) -> str: + def _has_agentplan_credential(blocks: Dict[str, Any]) -> bool: + return any( + cred.get("Source") == "agentplan" + for block in blocks.values() + for cred in block["Credentials"] + ) + + @classmethod + def _replay_note(cls, blocks: Dict[str, Any], explicit_key: bool) -> str: """Explain the replay in the result, since it rewrites stored credentials.""" + if explicit_key: + return ( + "The AgentPlan model credential of both VLM and Embedding was " + "overwritten with the supplied key; the collection's other " + "credentials were replayed unchanged." + ) note = ( "The control plane rebuilt both model configurations on this update, " "so the collection's existing credentials were replayed." ) - rewritten = any( - "ApiKey" in cred - for block in blocks.values() - for cred in block["Credentials"] - ) - if rewritten: + if cls._has_agentplan_credential(blocks): note += ( " The AgentPlan model credential was re-set to the key this " "client authenticates with." diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 5888e105..7830faa7 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -185,6 +185,7 @@ def update_collection( description: Optional[str] = None, pay_type: Optional[str] = None, seat_id: Optional[str] = None, + model_api_key: Optional[str] = None, ) -> Dict[str, Any]: """Update mutable fields of an OpenViking collection (UpdateOpenVikingCollection). @@ -192,10 +193,10 @@ def update_collection( THE USER before calling — this mutates a live library. This is also the way to SWITCH BILLING (volc_pay ↔ AgentPlan deduction, or re-bind a seat after it was unbound); omitting both pay_type and seat_id leaves billing untouched. - Model configuration is not sent by this tool, so description and billing - changes preserve existing VLM/Embedding credentials. NOTE: an empty/whitespace - description is a server-side no-op — the description can only be overwritten - with a non-empty value. + Model configuration is not sent unless model_api_key is given, so description + and billing changes preserve existing VLM/Embedding credentials. NOTE: an + empty/whitespace description is a server-side no-op — the description can only + be overwritten with a non-empty value. Args: resource_id: target library ResourceID. @@ -209,9 +210,14 @@ def update_collection( pay_type="agentplan_enterprise", forbidden otherwise. Copied manually by the user from the Ark console seat-management page; the server does NOT verify the seat exists. + model_api_key: AgentPlan API key to write as the library's MODEL + credential; VLM and Embedding always share one key. Only + pass a key the user supplied for this purpose — it + REPLACES the stored model credential. Omit to leave model + credentials untouched. Returns: - {"Success": true} + {"Success": true}, plus "Note" when model credentials were rewritten. """ try: return get_client().update_collection( @@ -219,6 +225,7 @@ def update_collection( description=description, pay_type=pay_type, seat_id=seat_id, + model_api_key=model_api_key, ) except Exception as e: logger.error(f"update_collection failed: {e}") diff --git a/server/mcp_server_openviking_controlplane/tests/test_collection_update.py b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py index 35f2ae4b..42a15960 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_collection_update.py +++ b/server/mcp_server_openviking_controlplane/tests/test_collection_update.py @@ -218,6 +218,139 @@ def test_unrelated_errors_are_not_retried(self): self.assertEqual(request.call_count, 1) + def test_explicit_agentplan_key_overwrites_model_credentials(self): + collection = { + "VLM": { + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [ + {"Source": "agentplan", "Provider": "volcengine"}, + { + "Source": "volcengine", + "Provider": "volcengine", + "ApiKeyID": "4131627", + "EndpointID": "ep-vlm", + }, + ], + }, + "Embedding": { + "ModelName": "doubao-embedding-vision", + "Credentials": [{"Source": "agentplan", "Provider": "volcengine"}], + }, + } + + def respond(action, body): + if action == "GetOpenVikingCollection": + return collection + return {"Success": True} + + with patch.object(self.client, "_request", side_effect=respond) as request: + result = self.client.update_collection( + "ov-example", + description="new description", + model_api_key="ark-model-override", + ) + + self.assertEqual( + [call.args[0] for call in request.call_args_list], + ["GetOpenVikingCollection", "UpdateOpenVikingCollection"], + ) + sent = request.call_args_list[-1].args[1] + self.assertEqual(sent["Description"], "new description") + # One supplied key, written to both models; siblings replayed untouched. + self.assertEqual( + sent["VLM"]["Credentials"], + [ + { + "Source": "agentplan", + "Provider": "volcengine", + "ApiKey": "ark-model-override", + }, + { + "Source": "volcengine", + "Provider": "volcengine", + "ApiKeyID": "4131627", + "EndpointID": "ep-vlm", + }, + ], + ) + self.assertEqual( + sent["Embedding"]["Credentials"], + [ + { + "Source": "agentplan", + "Provider": "volcengine", + "ApiKey": "ark-model-override", + } + ], + ) + self.assertIn("overwritten", result["Note"]) + + def test_explicit_key_overrides_an_agentplan_credential_stored_by_id(self): + collection = { + "VLM": { + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [{"Source": "agentplan", "ApiKeyID": "4131600"}], + }, + "Embedding": { + "ModelName": "doubao-embedding-vision", + "Credentials": [{"Source": "agentplan", "ApiKeyID": "4131600"}], + }, + } + + def respond(action, body): + return collection if action == "GetOpenVikingCollection" else {"Success": True} + + with patch.object(self.client, "_request", side_effect=respond) as request: + self.client.update_collection("ov-example", model_api_key="ark-new") + + sent = request.call_args_list[-1].args[1] + self.assertEqual( + sent["VLM"]["Credentials"], [{"Source": "agentplan", "ApiKey": "ark-new"}] + ) + + def test_explicit_key_is_refused_without_an_agentplan_credential(self): + collection = { + "VLM": { + "ModelName": "doubao-seed-2.0-lite", + "Credentials": [ + {"Source": "volcengine", "ApiKeyID": "4131627", "EndpointID": "ep"} + ], + }, + "Embedding": { + "ModelName": "doubao-embedding-vision", + "Credentials": [ + {"Source": "volcengine", "ApiKeyID": "4131628", "EndpointID": "ep2"} + ], + }, + } + + def respond(action, body): + return collection if action == "GetOpenVikingCollection" else {"Success": True} + + with patch.object(self.client, "_request", side_effect=respond) as request: + with self.assertRaises(ControlPlaneError) as raised: + self.client.update_collection("ov-example", model_api_key="ark-new") + + self.assertEqual(raised.exception.code, "CredentialNotReplayable") + self.assertNotIn( + "UpdateOpenVikingCollection", + [call.args[0] for call in request.call_args_list], + ) + + def test_explicit_key_conflicts_with_an_explicit_model_block(self): + with patch.object(self.client, "_request") as request: + with self.assertRaises(ValueError): + self.client.update_collection( + "ov-example", + model_api_key="ark-new", + vlm={"Credentials": [{"Source": "agentplan", "ApiKey": "ark-x"}]}, + ) + request.assert_not_called() + + def test_model_api_key_is_exposed_on_every_update_entry_point(self): + for entry in (self.client.update_collection, server.update_collection, update_cmd): + self.assertIn("model_api_key", signature(entry).parameters) + def test_removed_version_update_is_not_exposed(self): self.assertNotIn("openviking_version", signature(self.client.update_collection).parameters) self.assertNotIn("openviking_version", signature(server.update_collection).parameters) From b1256649ce81911f027fc2fc1e47409cfd4eecec Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Fri, 21 Aug 2026 17:26:38 +0800 Subject: [PATCH 16/20] fix(openviking-controlplane): pin the MCP SDK below 2.0 mcp 2.0.0 drops FastMCP for MCPServer, so an unbounded requirement makes `uvx --from git+... mcp-server-openviking-controlplane` fail at import. Cap the requirement at <2 and import FastMCP from its canonical mcp.server.fastmcp path, which is stable across the 1.x line. --- server/mcp_server_openviking_controlplane/pyproject.toml | 2 +- .../src/mcp_server_openviking_controlplane/server.py | 2 +- server/mcp_server_openviking_controlplane/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/pyproject.toml b/server/mcp_server_openviking_controlplane/pyproject.toml index 7d401d89..e6896072 100644 --- a/server/mcp_server_openviking_controlplane/pyproject.toml +++ b/server/mcp_server_openviking_controlplane/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} dependencies = [ - "mcp[cli]>=1.5.0", + "mcp[cli]>=1.5.0,<2", "requests>=2.31.0", "rich>=13.8.0", "typer>=0.12.0", diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 7830faa7..2e4509e3 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -3,7 +3,7 @@ import os from typing import Any, Dict, Optional -from mcp.server import FastMCP +from mcp.server.fastmcp import FastMCP from mcp_server_openviking_controlplane.client import ControlPlaneError, get_client diff --git a/server/mcp_server_openviking_controlplane/uv.lock b/server/mcp_server_openviking_controlplane/uv.lock index 63aded08..dda3f83f 100644 --- a/server/mcp_server_openviking_controlplane/uv.lock +++ b/server/mcp_server_openviking_controlplane/uv.lock @@ -475,7 +475,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "mcp", extras = ["cli"], specifier = ">=1.5.0" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.5.0,<2" }, { name = "requests", specifier = ">=2.31.0" }, { name = "rich", specifier = ">=13.8.0" }, { name = "typer", specifier = ">=0.12.0" }, From 352910377d545db4a2322ce171f48ae2789adde6 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Wed, 26 Aug 2026 19:23:36 +0800 Subject: [PATCH 17/20] feat(openviking-controlplane): support stateless streamable HTTP transport A horizontally scaled gateway cannot pin an MCP session to one backend instance, so the server has to accept requests that carry no Mcp-Session-Id. Enable stateless HTTP and offer streamable-http alongside stdio and sse. host now has to be set explicitly: FastMCP defaults to 127.0.0.1, which auto-enables DNS-rebinding protection allowing only localhost Host headers, so every gateway-forwarded request would be rejected. MCP_SERVER_HOST / MCP_SERVER_PORT match the names the other servers here already use; PORT stays honoured. STATLESS_HTTP is this repo's (misspelled) standard name, with STATELESS_HTTP accepted as an alias so a correct spelling is not silently ignored. stateless_http only feeds StreamableHTTPSessionManager, so stdio and sse are unaffected. --- .../server.py | 20 ++++- .../tests/test_server_transport.py | 89 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_server_transport.py diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 2e4509e3..5e9f3906 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -12,14 +12,26 @@ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) -# Create MCP server +# Create MCP server. +# +# host must be set explicitly: FastMCP defaults to 127.0.0.1 and then auto-enables +# DNS-rebinding protection that only allows localhost Host headers, so a server +# behind a gateway or load balancer would reject every request. mcp = FastMCP( "OpenViking Control Plane MCP Server", - port=int(os.getenv("PORT", "8000")), + host=os.getenv("MCP_SERVER_HOST", "0.0.0.0"), + port=int(os.getenv("MCP_SERVER_PORT") or os.getenv("PORT", "8000")), streamable_http_path=os.getenv("STREAMABLE_HTTP_PATH", "/mcp"), + # STATLESS_HTTP is the (misspelled) name the other servers in this repo already + # use and document; STATELESS_HTTP is accepted as a correct-spelling alias so a + # right-spelled config is not silently ignored. Stateless is the default: it is + # what a horizontally scaled gateway needs, and it is a no-op for stdio and sse. + stateless_http=os.getenv("STATLESS_HTTP", os.getenv("STATELESS_HTTP", "true")).lower() + == "true", ) + def _err(e: Exception) -> Dict[str, Any]: if isinstance(e, ControlPlaneError): return {"error": {"code": e.code, "message": e.message, "request_id": e.request_id}} @@ -369,9 +381,9 @@ def main(): parser.add_argument( "--transport", "-t", - choices=["sse", "stdio"], + choices=["sse", "stdio", "streamable-http"], default="stdio", - help="Transport protocol to use (sse or stdio)", + help="Transport protocol to use (sse, stdio or streamable-http)", ) args = parser.parse_args() logger.info(f"Starting OpenViking Control Plane MCP Server with {args.transport} transport") diff --git a/server/mcp_server_openviking_controlplane/tests/test_server_transport.py b/server/mcp_server_openviking_controlplane/tests/test_server_transport.py new file mode 100644 index 00000000..64d70aa3 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_server_transport.py @@ -0,0 +1,89 @@ +"""Transport wiring: stateless streamable HTTP.""" + +import importlib +import os +import sys +import unittest +from unittest.mock import patch + +from mcp_server_openviking_controlplane import server + + +def _reload_server(**env): + """Reload the server module with ``env`` applied, returning the fresh module.""" + with patch.dict(os.environ, env, clear=False): + return importlib.reload(server) + + +class TransportDefaultsTest(unittest.TestCase): + """The settings the gateway depends on, asserted directly.""" + + def test_stateless_http_is_on_by_default(self): + self.assertTrue(server.mcp.settings.stateless_http) + + def test_binds_all_interfaces_so_dns_rebinding_protection_stays_off(self): + # FastMCP auto-enables a localhost-only Host allowlist when host is + # 127.0.0.1/localhost/::1, which would reject every gateway-forwarded + # request. Binding 0.0.0.0 is what keeps transport_security unset. + self.assertEqual(server.mcp.settings.host, "0.0.0.0") + self.assertIsNone(server.mcp.settings.transport_security) + + def test_streamable_http_is_mounted_at_mcp(self): + self.assertEqual(server.mcp.settings.streamable_http_path, "/mcp") + + +class TransportSelectionTest(unittest.TestCase): + """``main()`` must hand FastMCP a transport string the SDK actually accepts.""" + + def _run_main(self, argv): + with patch.object(server.mcp, "run") as run: + with patch.object(sys, "argv", ["mcp-server-openviking-controlplane"] + argv): + server.main() + return run + + def test_defaults_to_stdio(self): + self.assertEqual(self._run_main([]).call_args.kwargs["transport"], "stdio") + + def test_accepts_every_transport_the_sdk_supports(self): + for transport in ("stdio", "sse", "streamable-http"): + with self.subTest(transport=transport): + run = self._run_main(["--transport", transport]) + self.assertEqual(run.call_args.kwargs["transport"], transport) + + def test_rejects_the_underscore_spelling(self): + # FastMCP.run types transport as Literal["stdio", "sse", "streamable-http"], + # so the underscore form would be a runtime ValueError deep inside the SDK. + # argparse must reject it first. + with patch.object(server.mcp, "run"): + with patch.object(sys, "argv", ["x", "--transport", "streamable_http"]): + with self.assertRaises(SystemExit): + with patch.object(sys, "stderr"): + server.main() + + +class StatelessEnvOverrideTest(unittest.TestCase): + """Both spellings of the opt-out are honoured; the repo's typo wins.""" + + def tearDown(self): + # Restore the module built from a clean environment for the rest of the suite. + for name in ("STATLESS_HTTP", "STATELESS_HTTP"): + os.environ.pop(name, None) + importlib.reload(server) + + def test_repo_standard_typo_disables_it(self): + self.assertFalse(_reload_server(STATLESS_HTTP="false").mcp.settings.stateless_http) + + def test_correct_spelling_also_disables_it(self): + self.assertFalse(_reload_server(STATELESS_HTTP="false").mcp.settings.stateless_http) + + def test_typo_takes_precedence_when_both_are_set(self): + module = _reload_server(STATLESS_HTTP="true", STATELESS_HTTP="false") + self.assertTrue(module.mcp.settings.stateless_http) + + def test_empty_port_falls_back_instead_of_crashing(self): + # `int(os.getenv("MCP_SERVER_PORT", ...))` would raise on an exported-but-empty + # variable and kill the process at import time. + self.assertEqual(_reload_server(MCP_SERVER_PORT="").mcp.settings.port, 8000) + +if __name__ == "__main__": + unittest.main() From 107e577451b7354cb4904e9544054b30fe98e727 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Wed, 26 Aug 2026 19:23:53 +0800 Subject: [PATCH 18/20] chore(openviking-controlplane): bump version to 0.2.0 and document PyPI install The publish workflow added in #415 keys off project.version, so a bump is what actually releases the package. 0.1.0 is already on PyPI and PyPI releases are immutable; that published artifact is stale (no output.py, no rich dependency, no mcp[cli]<2 upper pin). 0.2.0 rather than 0.1.1: the unreleased delta is the enterprise library tier, billing configuration, collection user management, custom request headers, the credential-replay fixes and now a new transport -- all backward compatible additions. Also document the pinned uvx invocation and the streamable HTTP run mode in both READMEs. --- .../README.md | 52 ++++++++++++++++++- .../README_zh.md | 50 +++++++++++++++++- .../pyproject.toml | 2 +- .../uv.lock | 2 +- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 124cec7a..03c57ee2 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -153,10 +153,33 @@ for testing (e.g. against a port-forward) with `-e` / `VIKING_ENDPOINT` — `uv run ov-cp -e http://localhost:18080 list`. `ov-cp --help` works without any config. -## MCP usage (stdio / uvx) +## MCP usage (stdio / uvx / streamable HTTP) The server defaults to **stdio** transport, so it can be launched as a subprocess by -any MCP client. Add to `.mcp.json`: +any MCP client, and can also be served over stateless streamable HTTP behind a +gateway. Add to `.mcp.json`: + +### Install from PyPI + +```json +{ + "mcpServers": { + "openviking-controlplane": { + "command": "uvx", + "args": [ + "--from", + "mcp-server-openviking-controlplane>=0.2.0", + "mcp-server-openviking-controlplane" + ], + "env": { + "AGENTPLAN_API_KEY": "ark-xxxxxxxx" + } + } + } +} +``` + +### Install from source ```json { @@ -193,6 +216,31 @@ For local development point it at your checkout instead: } ``` +### Streamable HTTP (stateless) + +```bash +mcp-server-openviking-controlplane --transport streamable-http +# -> http://0.0.0.0:8000/mcp +``` + +Stateless is the default: every request carries its own context, so no request +depends on a prior `Mcp-Session-Id` and the process can be scaled horizontally +behind a gateway. + +| Env var | Meaning | Default | +|---|---|---| +| `MCP_SERVER_HOST` | HTTP bind address | `0.0.0.0` | +| `MCP_SERVER_PORT` | HTTP port (`PORT` is still honoured) | `8000` | +| `STREAMABLE_HTTP_PATH` | Mount path for streamable HTTP | `/mcp` | +| `STATLESS_HTTP` | Enable stateless HTTP (`STATELESS_HTTP` also works) | `true` | + +> Binding `127.0.0.1` makes the MCP SDK enable DNS-rebinding protection, which +> only allows localhost `Host` headers — a gateway-forwarded request would then be +> rejected. Keep the `0.0.0.0` default when running behind one. + +> Under HTTP transports every request is served with the process's own +> `AGENTPLAN_API_KEY`, so one deployment serves one AgentPlan account. + Run with SSE instead via `mcp-server-openviking-controlplane --transport sse`. ## Agent skill diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 7406614c..1279b44b 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -139,9 +139,32 @@ uv run ov-cp --output pretty list # 强制终端视图 `-e` / `VIKING_ENDPOINT` 覆盖:`uv run ov-cp -e http://localhost:18080 list`。 `ov-cp --help` 不需要任何配置即可运行。 -## MCP 用法(stdio / uvx) +## MCP 用法(stdio / uvx / streamable HTTP) -Server 默认 **stdio** 传输,可被任意 MCP 客户端作为子进程拉起。`.mcp.json` 配置: +Server 默认 **stdio** 传输,可被任意 MCP 客户端作为子进程拉起;也可以以无状态 +streamable HTTP 的方式挂在网关后面。`.mcp.json` 配置: + +### 从 PyPI 安装 + +```json +{ + "mcpServers": { + "openviking-controlplane": { + "command": "uvx", + "args": [ + "--from", + "mcp-server-openviking-controlplane>=0.2.0", + "mcp-server-openviking-controlplane" + ], + "env": { + "AGENTPLAN_API_KEY": "ark-xxxxxxxx" + } + } + } +} +``` + +### 从源码安装 ```json { @@ -178,6 +201,29 @@ Server 默认 **stdio** 传输,可被任意 MCP 客户端作为子进程拉起 } ``` +### Streamable HTTP(无状态) + +```bash +mcp-server-openviking-controlplane --transport streamable-http +# -> http://0.0.0.0:8000/mcp +``` + +默认即无状态:每个请求自带完整上下文,不依赖上一次返回的 `Mcp-Session-Id`, +因此进程可以在网关后面水平扩缩。 + +| 环境变量 | 含义 | 默认值 | +|---|---|---| +| `MCP_SERVER_HOST` | HTTP 监听地址 | `0.0.0.0` | +| `MCP_SERVER_PORT` | HTTP 端口(`PORT` 仍然有效) | `8000` | +| `STREAMABLE_HTTP_PATH` | streamable HTTP 挂载路径 | `/mcp` | +| `STATLESS_HTTP` | 是否启用无状态 HTTP(`STATELESS_HTTP` 亦可) | `true` | + +> 监听 `127.0.0.1` 会让 MCP SDK 自动开启 DNS-rebinding 保护,只放行 localhost 的 +> `Host` 头——网关转发过来的请求会被拒。挂在网关后面时请保持 `0.0.0.0` 默认值。 + +> HTTP 传输下所有请求都用进程自身的 `AGENTPLAN_API_KEY`, +> 因此一个部署对应一个 AgentPlan 账号。 + 需要 SSE 时:`mcp-server-openviking-controlplane --transport sse`。 > ⚠️ `create_collection` / `delete_collection` 会创建/销毁**付费**资源,且已暴露为 MCP diff --git a/server/mcp_server_openviking_controlplane/pyproject.toml b/server/mcp_server_openviking_controlplane/pyproject.toml index e6896072..056c8d20 100644 --- a/server/mcp_server_openviking_controlplane/pyproject.toml +++ b/server/mcp_server_openviking_controlplane/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-openviking-controlplane" -version = "0.1.0" +version = "0.2.0" description = "MCP server and CLI for the OpenViking control plane (topapi) collection management" readme = "README.md" requires-python = ">=3.10" diff --git a/server/mcp_server_openviking_controlplane/uv.lock b/server/mcp_server_openviking_controlplane/uv.lock index dda3f83f..be6c4c38 100644 --- a/server/mcp_server_openviking_controlplane/uv.lock +++ b/server/mcp_server_openviking_controlplane/uv.lock @@ -464,7 +464,7 @@ cli = [ [[package]] name = "mcp-server-openviking-controlplane" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "mcp", extra = ["cli"] }, From 88ebc882581ff6090cb850c03b6b0f2d9b8798dc Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Wed, 26 Aug 2026 19:24:04 +0800 Subject: [PATCH 19/20] feat(openviking-controlplane): resolve AgentPlan credentials per request Under stateless HTTP the module-level client singleton would transact every request with whatever credential the process started with. That is not merely untidy here: get_collection_api_key returns a plaintext data-plane key, and create/update_collection store the configured key as the collection's model credential, so a shared deployment would hand one caller's credential to another. Resolve the key from the request instead, falling back to the environment: X-AgentPlan-Api-Key, then Authorization, then AGENTPLAN_API_KEY. Only the Bearer scheme is read from Authorization -- a gateway terminating its own auth there must not have that value used as an Ark key. get_client keeps its name and signature, so the tools and their tests are unchanged. config.get_config() is removed rather than left in place: a cached environment config would silently defeat per-request resolution, and client.py was its only importer. stdio is unaffected -- there is no request context, and both lookups fall through to the environment. --- .../README.md | 16 ++++- .../README_zh.md | 14 ++++- .../client.py | 29 ++++++--- .../config.py | 11 ---- .../server.py | 49 ++++++++++++++- .../tests/test_server_transport.py | 61 ++++++++++++++++++- 6 files changed, 153 insertions(+), 27 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 03c57ee2..f0a923e5 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -238,8 +238,20 @@ behind a gateway. > only allows localhost `Host` headers — a gateway-forwarded request would then be > rejected. Keep the `0.0.0.0` default when running behind one. -> Under HTTP transports every request is served with the process's own -> `AGENTPLAN_API_KEY`, so one deployment serves one AgentPlan account. +#### Credentials over HTTP + +Under HTTP transports the AgentPlan ApiKey is resolved **per request**, so one +process can serve several callers: + +| Source | Precedence | +|---|---| +| `X-AgentPlan-Api-Key` header | 1 (highest) | +| `Authorization: Bearer ` header | 2 | +| `AGENTPLAN_API_KEY` env var | 3 (fallback) | + +Only the `Bearer` scheme is read from `Authorization`; any other scheme is ignored +and the env var is used instead, so a gateway that terminates its own auth there +does not leak its credential into a caller's collection. Run with SSE instead via `mcp-server-openviking-controlplane --transport sse`. diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 1279b44b..d3721e8d 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -221,8 +221,18 @@ mcp-server-openviking-controlplane --transport streamable-http > 监听 `127.0.0.1` 会让 MCP SDK 自动开启 DNS-rebinding 保护,只放行 localhost 的 > `Host` 头——网关转发过来的请求会被拒。挂在网关后面时请保持 `0.0.0.0` 默认值。 -> HTTP 传输下所有请求都用进程自身的 `AGENTPLAN_API_KEY`, -> 因此一个部署对应一个 AgentPlan 账号。 +#### HTTP 下的凭证来源 + +HTTP 传输下 AgentPlan ApiKey **按请求解析**,因此单个进程可以服务多个调用方: + +| 来源 | 优先级 | +|---|---| +| `X-AgentPlan-Api-Key` 请求头 | 1(最高) | +| `Authorization: Bearer ` 请求头 | 2 | +| `AGENTPLAN_API_KEY` 环境变量 | 3(兜底) | + +`Authorization` 只读取 `Bearer` scheme,其他 scheme 一律忽略并回落到环境变量—— +这样网关若在该头上终结自己的鉴权,其凭证不会被写进调用方的库。 需要 SSE 时:`mcp-server-openviking-controlplane --transport sse`。 diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py index f38b62b1..1e7abba7 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/client.py @@ -12,7 +12,7 @@ PAY_TYPE_MAP, VERSION_CHOICES, ControlPlaneConfig, - get_config, + build_config, ) logger = logging.getLogger(__name__) @@ -588,12 +588,23 @@ def delete_user(self, resource_id: str, user_id: str) -> Dict[str, Any]: ) -_client: Optional[ControlPlaneClient] = None +def build_client( + api_key: Optional[str] = None, + endpoint: Optional[str] = None, + project: Optional[str] = None, + extra_headers: Optional[Dict[str, str]] = None, +) -> ControlPlaneClient: + """Build a control-plane client from explicit args first, then the environment. - -def get_client() -> ControlPlaneClient: - """Lazy singleton used by the MCP server (config resolved from the environment).""" - global _client - if _client is None: - _client = ControlPlaneClient(get_config()) - return _client + Deliberately not cached. The MCP server resolves the caller's credential per + request, and a client caches nothing expensive: it holds no requests.Session + and opens no sockets until a method is called. + """ + return ControlPlaneClient( + build_config( + endpoint=endpoint, + project=project, + api_key=api_key, + extra_headers=extra_headers, + ) + ) diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py index f8db4daa..9523dc71 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/config.py @@ -128,14 +128,3 @@ def build_config( project=project or os.environ.get("OPENVIKING_PROJECT", DEFAULT_PROJECT), extra_headers=headers, ) - - -_config: Optional[ControlPlaneConfig] = None - - -def get_config() -> ControlPlaneConfig: - """Lazy, cached config built purely from the environment (used by the MCP server).""" - global _config - if _config is None: - _config = build_config() - return _config diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 5e9f3906..13fd2c72 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -5,7 +5,11 @@ from mcp.server.fastmcp import FastMCP -from mcp_server_openviking_controlplane.client import ControlPlaneError, get_client +from mcp_server_openviking_controlplane.client import ( + ControlPlaneClient, + ControlPlaneError, + build_client, +) logger = logging.getLogger(__name__) logging.basicConfig( @@ -31,6 +35,49 @@ ) +# Header a gateway can use to forward the caller's Ark AgentPlan ApiKey when it needs +# Authorization for its own auth. Checked before Authorization. +_API_KEY_HEADER = "x-agentplan-api-key" + + +def _request_api_key() -> Optional[str]: + """The Ark AgentPlan ApiKey carried by the request currently being served, if any. + + Returns None under stdio (there is no HTTP request) and None when the request + carries no usable credential, in which case the client falls back to the + AGENTPLAN_API_KEY environment variable. + """ + try: + raw_request = mcp.get_context().request_context.request + except (ValueError, LookupError, AttributeError): + return None + if raw_request is None: + return None + + headers = raw_request.headers + key = (headers.get(_API_KEY_HEADER) or "").strip() + if key: + return key + + # Only the Bearer scheme is accepted: a gateway that puts its OWN credential in + # Authorization must not have it used as an Ark key -- the configured key is not + # merely replayed as a header, it is stored as the model credential of the + # collections this server creates. + scheme, _, rest = (headers.get("authorization") or "").strip().partition(" ") + if scheme.lower() == "bearer" and rest.strip(): + return rest.strip() + return None + + +def get_client() -> ControlPlaneClient: + """Build the control-plane client for the request currently being served. + + Deliberately not cached: under stateless HTTP a module-level singleton would + transact every request with whatever credential the process started with. + Construction is cheap -- the client holds no requests.Session and no sockets. + """ + return build_client(api_key=_request_api_key()) + def _err(e: Exception) -> Dict[str, Any]: if isinstance(e, ControlPlaneError): diff --git a/server/mcp_server_openviking_controlplane/tests/test_server_transport.py b/server/mcp_server_openviking_controlplane/tests/test_server_transport.py index 64d70aa3..fb5a6c94 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_server_transport.py +++ b/server/mcp_server_openviking_controlplane/tests/test_server_transport.py @@ -1,10 +1,12 @@ -"""Transport wiring: stateless streamable HTTP.""" +"""Transport wiring: stateless streamable HTTP and per-request credentials.""" import importlib import os import sys import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch + +from starlette.datastructures import Headers from mcp_server_openviking_controlplane import server @@ -85,5 +87,60 @@ def test_empty_port_falls_back_instead_of_crashing(self): # variable and kill the process at import time. self.assertEqual(_reload_server(MCP_SERVER_PORT="").mcp.settings.port, 8000) + +class RequestCredentialTest(unittest.TestCase): + """The credential is resolved per request, not once per process.""" + + def _with_headers(self, headers): + context = Mock() + context.request_context.request.headers = Headers(headers) + return patch.object(server.mcp, "get_context", return_value=context) + + def test_dedicated_header_is_used_verbatim(self): + with self._with_headers({"X-AgentPlan-Api-Key": "ark-caller"}): + self.assertEqual(server._request_api_key(), "ark-caller") + + def test_bearer_scheme_is_stripped(self): + with self._with_headers({"Authorization": "Bearer ark-caller"}): + self.assertEqual(server._request_api_key(), "ark-caller") + + def test_dedicated_header_beats_authorization(self): + with self._with_headers( + {"X-AgentPlan-Api-Key": "ark-dedicated", "Authorization": "Bearer ark-auth"} + ): + self.assertEqual(server._request_api_key(), "ark-dedicated") + + def test_non_bearer_authorization_is_ignored(self): + # A gateway terminating its own auth may put an unrelated credential here. + # Using it as an Ark key would stamp it into the caller's collection. + for value in ("Basic dXNlcjpwYXNz", "opaque-gateway-token", "Bearer "): + with self.subTest(value=value): + with self._with_headers({"Authorization": value}): + self.assertIsNone(server._request_api_key()) + + def test_no_http_request_means_no_request_key(self): + # stdio: get_context() raises outside a request, and .request is None inside one. + with patch.object(server.mcp, "get_context", side_effect=ValueError): + self.assertIsNone(server._request_api_key()) + + context = Mock() + context.request_context.request = None + with patch.object(server.mcp, "get_context", return_value=context): + self.assertIsNone(server._request_api_key()) + + def test_consecutive_requests_do_not_share_a_credential(self): + with patch.dict(os.environ, {"AGENTPLAN_API_KEY": "ark-env"}, clear=False): + with self._with_headers({"X-AgentPlan-Api-Key": "ark-first"}): + first = server.get_client() + with self._with_headers({"X-AgentPlan-Api-Key": "ark-second"}): + second = server.get_client() + with patch.object(server.mcp, "get_context", side_effect=ValueError): + fallback = server.get_client() + + self.assertEqual(first.config.api_key, "ark-first") + self.assertEqual(second.config.api_key, "ark-second") + self.assertEqual(fallback.config.api_key, "ark-env") + + if __name__ == "__main__": unittest.main() From 970d1f15a148aef900191a37747a825092bfa614 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Wed, 26 Aug 2026 19:44:29 +0800 Subject: [PATCH 20/20] feat(openviking-controlplane): upgrade to MCP protocol 2026-07-28 (mcp SDK 2.x) `stateless_http` on the 1.x FastMCP only makes the HTTP session layer stateless; the wire protocol stays a handshake-era revision (2025-11-25 at best). The 2026-07-28 revision is the one mcp-types documents as "protocol revisions that use the stateless per-request envelope" -- no `initialize`, `server/discover` instead, protocol version carried in `params._meta` -- and it exists only in the mcp 2.x line. Move to it: `mcp.server.fastmcp` is a raising shim in 2.x, so FastMCP becomes `mcp.server.mcpserver.MCPServer`, and host / port / streamable_http_path / stateless_http move from constructor settings to run() arguments under the same names. Reverts the `mcp[cli]<2` cap. MCPServer has no get_context(), so the request is reached through a Context parameter injected per tool. Context-typed parameters are excluded from the generated JSON schema, so all 11 tools keep their exact input and output schemas; Context.headers replaces the request_context walk. Verified with the SDK's own client: mode="2026-07-28" and mode="auto" both negotiate 2026-07-28, mode="legacy" still negotiates 2025-11-25, and stdio is unaffected. --- .../README.md | 5 + .../README_zh.md | 4 + .../pyproject.toml | 2 +- .../server.py | 117 +++++++------ .../tests/test_server_transport.py | 157 +++++++++--------- .../uv.lock | 104 +++++++----- 6 files changed, 224 insertions(+), 165 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index f0a923e5..b3b44a9f 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -227,6 +227,11 @@ Stateless is the default: every request carries its own context, so no request depends on a prior `Mcp-Session-Id` and the process can be scaled horizontally behind a gateway. +The server speaks MCP protocol revision **2026-07-28** — the per-request-envelope +revision, reached via `server/discover` rather than an `initialize` handshake — and +still negotiates the older handshake revisions (down to `2024-11-05`) for clients +that ask for them. This requires the mcp SDK 2.x line. + | Env var | Meaning | Default | |---|---|---| | `MCP_SERVER_HOST` | HTTP bind address | `0.0.0.0` | diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index d3721e8d..db78349a 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -211,6 +211,10 @@ mcp-server-openviking-controlplane --transport streamable-http 默认即无状态:每个请求自带完整上下文,不依赖上一次返回的 `Mcp-Session-Id`, 因此进程可以在网关后面水平扩缩。 +Server 支持 MCP 协议修订版 **2026-07-28**——即"每请求信封"修订版,通过 +`server/discover` 探测而非 `initialize` 握手协商——同时仍可与要求旧握手修订版 +(最低 `2024-11-05`)的客户端协商。该能力需要 mcp SDK 2.x。 + | 环境变量 | 含义 | 默认值 | |---|---|---| | `MCP_SERVER_HOST` | HTTP 监听地址 | `0.0.0.0` | diff --git a/server/mcp_server_openviking_controlplane/pyproject.toml b/server/mcp_server_openviking_controlplane/pyproject.toml index 056c8d20..5f909fcd 100644 --- a/server/mcp_server_openviking_controlplane/pyproject.toml +++ b/server/mcp_server_openviking_controlplane/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} dependencies = [ - "mcp[cli]>=1.5.0,<2", + "mcp[cli]>=2.1.1,<3", "requests>=2.31.0", "rich>=13.8.0", "typer>=0.12.0", diff --git a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py index 13fd2c72..b6ee8cdf 100644 --- a/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py +++ b/server/mcp_server_openviking_controlplane/src/mcp_server_openviking_controlplane/server.py @@ -3,7 +3,8 @@ import os from typing import Any, Dict, Optional -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context from mcp_server_openviking_controlplane.client import ( ControlPlaneClient, @@ -16,45 +17,44 @@ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) -# Create MCP server. -# -# host must be set explicitly: FastMCP defaults to 127.0.0.1 and then auto-enables -# DNS-rebinding protection that only allows localhost Host headers, so a server -# behind a gateway or load balancer would reject every request. -mcp = FastMCP( - "OpenViking Control Plane MCP Server", - host=os.getenv("MCP_SERVER_HOST", "0.0.0.0"), - port=int(os.getenv("MCP_SERVER_PORT") or os.getenv("PORT", "8000")), - streamable_http_path=os.getenv("STREAMABLE_HTTP_PATH", "/mcp"), - # STATLESS_HTTP is the (misspelled) name the other servers in this repo already - # use and document; STATELESS_HTTP is accepted as a correct-spelling alias so a - # right-spelled config is not silently ignored. Stateless is the default: it is - # what a horizontally scaled gateway needs, and it is a no-op for stdio and sse. - stateless_http=os.getenv("STATLESS_HTTP", os.getenv("STATELESS_HTTP", "true")).lower() - == "true", -) +# Create MCP server. Transport options (host, port, path, stateless) are arguments +# to run() in mcp 2.x rather than constructor settings. +mcp = MCPServer("OpenViking Control Plane MCP Server") + + +def _transport_options(transport: str) -> Dict[str, Any]: + """Transport-specific keyword arguments for MCPServer.run().""" + if transport == "stdio": + return {} + options: Dict[str, Any] = { + "host": os.getenv("MCP_SERVER_HOST", "0.0.0.0"), + "port": int(os.getenv("MCP_SERVER_PORT") or os.getenv("PORT", "8000")), + } + if transport == "streamable-http": + options["streamable_http_path"] = os.getenv("STREAMABLE_HTTP_PATH", "/mcp") + # STATLESS_HTTP is the (misspelled) name the other servers in this repo + # already use and document; STATELESS_HTTP is accepted as a correct-spelling + # alias so a right-spelled config is not silently ignored. + options["stateless_http"] = ( + os.getenv("STATLESS_HTTP", os.getenv("STATELESS_HTTP", "true")).lower() == "true" + ) + return options -# Header a gateway can use to forward the caller's Ark AgentPlan ApiKey when it needs -# Authorization for its own auth. Checked before Authorization. _API_KEY_HEADER = "x-agentplan-api-key" -def _request_api_key() -> Optional[str]: +def _request_api_key(ctx: Optional[Context]) -> Optional[str]: """The Ark AgentPlan ApiKey carried by the request currently being served, if any. - Returns None under stdio (there is no HTTP request) and None when the request - carries no usable credential, in which case the client falls back to the - AGENTPLAN_API_KEY environment variable. + Returns None under stdio (Context.headers is None when the transport carries no + request) and None when the request carries no usable credential, in which case + the client falls back to the AGENTPLAN_API_KEY environment variable. """ - try: - raw_request = mcp.get_context().request_context.request - except (ValueError, LookupError, AttributeError): - return None - if raw_request is None: + headers = ctx.headers if ctx is not None else None + if not headers: return None - headers = raw_request.headers key = (headers.get(_API_KEY_HEADER) or "").strip() if key: return key @@ -69,14 +69,14 @@ def _request_api_key() -> Optional[str]: return None -def get_client() -> ControlPlaneClient: +def get_client(ctx: Optional[Context] = None) -> ControlPlaneClient: """Build the control-plane client for the request currently being served. Deliberately not cached: under stateless HTTP a module-level singleton would transact every request with whatever credential the process started with. Construction is cheap -- the client holds no requests.Session and no sockets. """ - return build_client(api_key=_request_api_key()) + return build_client(api_key=_request_api_key(ctx)) def _err(e: Exception) -> Dict[str, Any]: @@ -86,7 +86,9 @@ def _err(e: Exception) -> Dict[str, Any]: @mcp.tool() -def list_collections(project: Optional[str] = None) -> Dict[str, Any]: +def list_collections( + project: Optional[str] = None, ctx: Optional[Context] = None +) -> Dict[str, Any]: """List OpenViking collections (OV libraries) under the configured account. Args: @@ -97,14 +99,16 @@ def list_collections(project: Optional[str] = None) -> Dict[str, Any]: {"Collections": [ ...CollectionInfoData... ]} """ try: - return get_client().list_collections(project=project) + return get_client(ctx).list_collections(project=project) except Exception as e: logger.error(f"list_collections failed: {e}") return _err(e) @mcp.tool() -def get_collection(resource_id: str) -> Dict[str, Any]: +def get_collection( + resource_id: str, ctx: Optional[Context] = None +) -> Dict[str, Any]: """Get basic info of one OpenViking collection by ResourceID. Args: @@ -116,14 +120,16 @@ def get_collection(resource_id: str) -> Dict[str, Any]: UpdateTime (Unix seconds), etc. """ try: - return get_client().get_collection(resource_id) + return get_client(ctx).get_collection(resource_id) except Exception as e: logger.error(f"get_collection failed: {e}") return _err(e) @mcp.tool() -def get_usage(resource_id: str) -> Dict[str, Any]: +def get_usage( + resource_id: str, ctx: Optional[Context] = None +) -> Dict[str, Any]: """Get overall usage / file counts for one OpenViking collection by ResourceID. Args: @@ -137,7 +143,7 @@ def get_usage(resource_id: str) -> Dict[str, Any]: three top-level dirs only; per-uri breakdown is not supported. """ try: - return get_client().get_usage(resource_id) + return get_client(ctx).get_usage(resource_id) except Exception as e: logger.error(f"get_usage failed: {e}") return _err(e) @@ -147,6 +153,7 @@ def get_usage(resource_id: str) -> Dict[str, Any]: def get_collection_api_key( resource_id: str, user_id: Optional[str] = None, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: """Get one user's plaintext data-plane API Key. @@ -164,7 +171,7 @@ def get_collection_api_key( {"UserID", "Role", "ApiKey"} """ try: - return get_client().get_user_access(resource_id, user_id=user_id) + return get_client(ctx).get_user_access(resource_id, user_id=user_id) except Exception as e: logger.error(f"get_collection_api_key failed: {e}") return _err(e) @@ -178,6 +185,7 @@ def create_collection( description: Optional[str] = None, pay_type: Optional[str] = None, seat_id: Optional[str] = None, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: """⚠️ Creates a NEW, BILLABLE OpenViking collection (provisions a Helm release). @@ -224,7 +232,7 @@ def create_collection( {"ResourceID": "...", "Success": true} """ try: - return get_client().create_collection( + return get_client(ctx).create_collection( name=name, source="agentplan", version=version, @@ -245,6 +253,7 @@ def update_collection( pay_type: Optional[str] = None, seat_id: Optional[str] = None, model_api_key: Optional[str] = None, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: """Update mutable fields of an OpenViking collection (UpdateOpenVikingCollection). @@ -279,7 +288,7 @@ def update_collection( {"Success": true}, plus "Note" when model credentials were rewritten. """ try: - return get_client().update_collection( + return get_client(ctx).update_collection( resource_id, description=description, pay_type=pay_type, @@ -298,6 +307,7 @@ def list_collection_users( role: Optional[str] = None, page: int = 1, limit: int = 20, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: """List the users registered under one OpenViking collection. @@ -316,7 +326,7 @@ def list_collection_users( {"UserList": [ {"UserID", "Role", "ApiKey" (masked)} ], "Total": N} """ try: - return get_client().list_collection_users( + return get_client(ctx).list_collection_users( resource_id, user_id=user_id, role=role, @@ -329,7 +339,9 @@ def list_collection_users( @mcp.tool() -def register_collection_user(resource_id: str, user_id: str) -> Dict[str, Any]: +def register_collection_user( + resource_id: str, user_id: str, ctx: Optional[Context] = None +) -> Dict[str, Any]: """Register a NEW user under an OpenViking collection (RegisterOpenVikingUser). Requires the AgentPlan key to be associated with the target library. CONFIRM @@ -344,7 +356,7 @@ def register_collection_user(resource_id: str, user_id: str) -> Dict[str, Any]: {"Success": true} """ try: - return get_client().register_user(resource_id, user_id) + return get_client(ctx).register_user(resource_id, user_id) except Exception as e: logger.error(f"register_collection_user failed: {e}") return _err(e) @@ -355,6 +367,7 @@ def update_collection_user( resource_id: str, user_id: str, regenerate_key: bool, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: """Update a user under an OpenViking collection (currently API Key rotation). @@ -371,7 +384,7 @@ def update_collection_user( {"Success": true} """ try: - return get_client().update_user( + return get_client(ctx).update_user( resource_id, user_id, regenerate_key=regenerate_key, @@ -382,7 +395,9 @@ def update_collection_user( @mcp.tool() -def delete_collection_user(resource_id: str, user_id: str) -> Dict[str, Any]: +def delete_collection_user( + resource_id: str, user_id: str, ctx: Optional[Context] = None +) -> Dict[str, Any]: """⚠️ Delete a user from an OpenViking collection (DeleteOpenVikingUser). CONFIRM WITH THE USER before calling. This revokes the user's credential and @@ -396,14 +411,16 @@ def delete_collection_user(resource_id: str, user_id: str) -> Dict[str, Any]: {"Success": true} """ try: - return get_client().delete_user(resource_id, user_id) + return get_client(ctx).delete_user(resource_id, user_id) except Exception as e: logger.error(f"delete_collection_user failed: {e}") return _err(e) @mcp.tool() -def delete_collection(resource_id: str) -> Dict[str, Any]: +def delete_collection( + resource_id: str, ctx: Optional[Context] = None +) -> Dict[str, Any]: """⚠️ IRREVERSIBLY deletes an OpenViking collection (uninstalls its Helm release). CONFIRM WITH THE USER before calling. This cannot be undone; all data in the @@ -416,7 +433,7 @@ def delete_collection(resource_id: str) -> Dict[str, Any]: {"Success": true} """ try: - return get_client().delete_collection(resource_id) + return get_client(ctx).delete_collection(resource_id) except Exception as e: logger.error(f"delete_collection failed: {e}") return _err(e) @@ -436,7 +453,7 @@ def main(): logger.info(f"Starting OpenViking Control Plane MCP Server with {args.transport} transport") try: - mcp.run(transport=args.transport) + mcp.run(transport=args.transport, **_transport_options(args.transport)) except Exception as e: logger.error(f"Error starting OpenViking Control Plane MCP Server: {str(e)}") raise diff --git a/server/mcp_server_openviking_controlplane/tests/test_server_transport.py b/server/mcp_server_openviking_controlplane/tests/test_server_transport.py index fb5a6c94..e0fee6d7 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_server_transport.py +++ b/server/mcp_server_openviking_controlplane/tests/test_server_transport.py @@ -1,41 +1,61 @@ """Transport wiring: stateless streamable HTTP and per-request credentials.""" -import importlib import os import sys import unittest from unittest.mock import Mock, patch +from mcp.server.mcpserver.context import Context from starlette.datastructures import Headers from mcp_server_openviking_controlplane import server -def _reload_server(**env): - """Reload the server module with ``env`` applied, returning the fresh module.""" - with patch.dict(os.environ, env, clear=False): - return importlib.reload(server) +class TransportOptionsTest(unittest.TestCase): + """The run() arguments the gateway depends on.""" + def test_stdio_takes_no_transport_options(self): + self.assertEqual(server._transport_options("stdio"), {}) -class TransportDefaultsTest(unittest.TestCase): - """The settings the gateway depends on, asserted directly.""" - - def test_stateless_http_is_on_by_default(self): - self.assertTrue(server.mcp.settings.stateless_http) + def test_streamable_http_is_stateless_and_mounted_at_mcp(self): + options = server._transport_options("streamable-http") + self.assertTrue(options["stateless_http"]) + self.assertEqual(options["streamable_http_path"], "/mcp") def test_binds_all_interfaces_so_dns_rebinding_protection_stays_off(self): - # FastMCP auto-enables a localhost-only Host allowlist when host is - # 127.0.0.1/localhost/::1, which would reject every gateway-forwarded - # request. Binding 0.0.0.0 is what keeps transport_security unset. - self.assertEqual(server.mcp.settings.host, "0.0.0.0") - self.assertIsNone(server.mcp.settings.transport_security) + # Binding 127.0.0.1 makes the SDK enable a localhost-only Host allowlist, + # which would reject every gateway-forwarded request. + for transport in ("sse", "streamable-http"): + with self.subTest(transport=transport): + self.assertEqual(server._transport_options(transport)["host"], "0.0.0.0") + + def test_sse_gets_no_stateless_option(self): + # stateless_http is only a streamable-http argument in mcp 2.x; passing it + # to the sse overload would be a TypeError. + self.assertNotIn("stateless_http", server._transport_options("sse")) + + def test_repo_standard_typo_disables_stateless(self): + with patch.dict(os.environ, {"STATLESS_HTTP": "false"}): + self.assertFalse(server._transport_options("streamable-http")["stateless_http"]) + + def test_correct_spelling_also_disables_stateless(self): + with patch.dict(os.environ, {"STATELESS_HTTP": "false"}): + self.assertFalse(server._transport_options("streamable-http")["stateless_http"]) - def test_streamable_http_is_mounted_at_mcp(self): - self.assertEqual(server.mcp.settings.streamable_http_path, "/mcp") + def test_typo_takes_precedence_when_both_are_set(self): + with patch.dict(os.environ, {"STATLESS_HTTP": "true", "STATELESS_HTTP": "false"}): + self.assertTrue(server._transport_options("streamable-http")["stateless_http"]) + + def test_port_env_vars(self): + with patch.dict(os.environ, {"MCP_SERVER_PORT": "9001"}): + self.assertEqual(server._transport_options("streamable-http")["port"], 9001) + # An exported-but-empty variable must fall through rather than raise at startup. + with patch.dict(os.environ, {"MCP_SERVER_PORT": ""}): + self.assertEqual(server._transport_options("streamable-http")["port"], 8000) class TransportSelectionTest(unittest.TestCase): - """``main()`` must hand FastMCP a transport string the SDK actually accepts.""" + """``main()`` must hand the SDK a transport string it actually accepts.""" def _run_main(self, argv): with patch.object(server.mcp, "run") as run: @@ -43,8 +63,9 @@ def _run_main(self, argv): server.main() return run - def test_defaults_to_stdio(self): - self.assertEqual(self._run_main([]).call_args.kwargs["transport"], "stdio") + def test_defaults_to_stdio_with_no_transport_options(self): + run = self._run_main([]) + self.assertEqual(run.call_args.kwargs, {"transport": "stdio"}) def test_accepts_every_transport_the_sdk_supports(self): for transport in ("stdio", "sse", "streamable-http"): @@ -52,10 +73,14 @@ def test_accepts_every_transport_the_sdk_supports(self): run = self._run_main(["--transport", transport]) self.assertEqual(run.call_args.kwargs["transport"], transport) + def test_streamable_http_receives_its_options(self): + kwargs = self._run_main(["--transport", "streamable-http"]).call_args.kwargs + self.assertTrue(kwargs["stateless_http"]) + self.assertEqual(kwargs["host"], "0.0.0.0") + def test_rejects_the_underscore_spelling(self): - # FastMCP.run types transport as Literal["stdio", "sse", "streamable-http"], - # so the underscore form would be a runtime ValueError deep inside the SDK. - # argparse must reject it first. + # MCPServer.run types transport as Literal["stdio", "sse", "streamable-http"], + # so the underscore form would be a ValueError deep inside the SDK. with patch.object(server.mcp, "run"): with patch.object(sys, "argv", ["x", "--transport", "streamable_http"]): with self.assertRaises(SystemExit): @@ -63,84 +88,66 @@ def test_rejects_the_underscore_spelling(self): server.main() -class StatelessEnvOverrideTest(unittest.TestCase): - """Both spellings of the opt-out are honoured; the repo's typo wins.""" - - def tearDown(self): - # Restore the module built from a clean environment for the rest of the suite. - for name in ("STATLESS_HTTP", "STATELESS_HTTP"): - os.environ.pop(name, None) - importlib.reload(server) - - def test_repo_standard_typo_disables_it(self): - self.assertFalse(_reload_server(STATLESS_HTTP="false").mcp.settings.stateless_http) - - def test_correct_spelling_also_disables_it(self): - self.assertFalse(_reload_server(STATELESS_HTTP="false").mcp.settings.stateless_http) - - def test_typo_takes_precedence_when_both_are_set(self): - module = _reload_server(STATLESS_HTTP="true", STATELESS_HTTP="false") - self.assertTrue(module.mcp.settings.stateless_http) - - def test_empty_port_falls_back_instead_of_crashing(self): - # `int(os.getenv("MCP_SERVER_PORT", ...))` would raise on an exported-but-empty - # variable and kill the process at import time. - self.assertEqual(_reload_server(MCP_SERVER_PORT="").mcp.settings.port, 8000) - - class RequestCredentialTest(unittest.TestCase): """The credential is resolved per request, not once per process.""" - def _with_headers(self, headers): - context = Mock() - context.request_context.request.headers = Headers(headers) - return patch.object(server.mcp, "get_context", return_value=context) + @staticmethod + def _context(headers): + request_context = Mock() + request_context.request.headers = Headers(headers) + return Context(request_context=request_context) def test_dedicated_header_is_used_verbatim(self): - with self._with_headers({"X-AgentPlan-Api-Key": "ark-caller"}): - self.assertEqual(server._request_api_key(), "ark-caller") + ctx = self._context({"X-AgentPlan-Api-Key": "ark-caller"}) + self.assertEqual(server._request_api_key(ctx), "ark-caller") def test_bearer_scheme_is_stripped(self): - with self._with_headers({"Authorization": "Bearer ark-caller"}): - self.assertEqual(server._request_api_key(), "ark-caller") + ctx = self._context({"Authorization": "Bearer ark-caller"}) + self.assertEqual(server._request_api_key(ctx), "ark-caller") def test_dedicated_header_beats_authorization(self): - with self._with_headers( + ctx = self._context( {"X-AgentPlan-Api-Key": "ark-dedicated", "Authorization": "Bearer ark-auth"} - ): - self.assertEqual(server._request_api_key(), "ark-dedicated") + ) + self.assertEqual(server._request_api_key(ctx), "ark-dedicated") def test_non_bearer_authorization_is_ignored(self): # A gateway terminating its own auth may put an unrelated credential here. # Using it as an Ark key would stamp it into the caller's collection. for value in ("Basic dXNlcjpwYXNz", "opaque-gateway-token", "Bearer "): with self.subTest(value=value): - with self._with_headers({"Authorization": value}): - self.assertIsNone(server._request_api_key()) + self.assertIsNone(server._request_api_key(self._context({"Authorization": value}))) - def test_no_http_request_means_no_request_key(self): - # stdio: get_context() raises outside a request, and .request is None inside one. - with patch.object(server.mcp, "get_context", side_effect=ValueError): - self.assertIsNone(server._request_api_key()) + def test_no_request_means_no_request_key(self): + self.assertIsNone(server._request_api_key(None)) # stdio: no Context injected - context = Mock() - context.request_context.request = None - with patch.object(server.mcp, "get_context", return_value=context): - self.assertIsNone(server._request_api_key()) + request_context = Mock() + request_context.request = None # HTTP transport without a request object + self.assertIsNone(server._request_api_key(Context(request_context=request_context))) def test_consecutive_requests_do_not_share_a_credential(self): with patch.dict(os.environ, {"AGENTPLAN_API_KEY": "ark-env"}, clear=False): - with self._with_headers({"X-AgentPlan-Api-Key": "ark-first"}): - first = server.get_client() - with self._with_headers({"X-AgentPlan-Api-Key": "ark-second"}): - second = server.get_client() - with patch.object(server.mcp, "get_context", side_effect=ValueError): - fallback = server.get_client() + first = server.get_client(self._context({"X-AgentPlan-Api-Key": "ark-first"})) + second = server.get_client(self._context({"X-AgentPlan-Api-Key": "ark-second"})) + fallback = server.get_client(None) self.assertEqual(first.config.api_key, "ark-first") self.assertEqual(second.config.api_key, "ark-second") self.assertEqual(fallback.config.api_key, "ark-env") +class ToolContractTest(unittest.TestCase): + """Injecting Context must not change what clients see.""" + + def test_ctx_is_not_exposed_as_a_tool_argument(self): + import anyio + + tools = anyio.run(server.mcp.list_tools) + self.assertEqual(len(tools), 11) + for tool in tools: + with self.subTest(tool=tool.name): + self.assertNotIn("ctx", tool.input_schema.get("properties") or {}) + + if __name__ == "__main__": unittest.main() diff --git a/server/mcp_server_openviking_controlplane/uv.lock b/server/mcp_server_openviking_controlplane/uv.lock index be6c4c38..73f574f2 100644 --- a/server/mcp_server_openviking_controlplane/uv.lock +++ b/server/mcp_server_openviking_controlplane/uv.lock @@ -2,7 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", "python_full_version < '3.11'", ] @@ -346,40 +348,42 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -433,15 +437,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.2" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -451,9 +455,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/6e/21fb8e5d579dbe21d96ea4d5034200d46d8bdf2261053b5bd041f3c2f612/mcp-2.1.1.tar.gz", hash = "sha256:50b7ba1ebbe117008ea7bdd288234043e69c20b403d6851d19661e6d431a75ef", size = 3984589, upload-time = "2026-08-25T16:14:02.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/50/af/8644cc5fa26a59afd2df2e98eeb19e72926887fa4b7441aba4ff661140db/mcp-2.1.1-py3-none-any.whl", hash = "sha256:1c6c31c5d6471c58db76af3af8af67f46d11d01f0a59077d0a308cbdb3d3e915", size = 357912, upload-time = "2026-08-25T16:13:59.024Z" }, ] [package.optional-dependencies] @@ -475,12 +479,25 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "mcp", extras = ["cli"], specifier = ">=1.5.0,<2" }, + { name = "mcp", extras = ["cli"], specifier = ">=2.1.1,<3" }, { name = "requests", specifier = ">=2.31.0" }, { name = "rich", specifier = ">=13.8.0" }, { name = "typer", specifier = ">=0.12.0" }, ] +[[package]] +name = "mcp-types" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/dd/1c4417dc0b722c23a1669032d5f044e41170fe5d4773b488a50fcce98c32/mcp_types-2.1.1.tar.gz", hash = "sha256:77dcbe48fba73cca71a673f2646a5f037a017b7a0a07ac89cec1113028890eda", size = 66674, upload-time = "2026-08-25T16:14:03.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/d0/242e63c510f4a17381f55b1549a3f94f5687a0595984febd2b6f87a687a0/mcp_types-2.1.1-py3-none-any.whl", hash = "sha256:26f9f7f03f2a5730717a5b98e2ab7eb640ac352d05a00cdc725c311864778295", size = 69656, upload-time = "2026-08-25T16:14:00.667Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -490,6 +507,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -630,20 +659,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -883,7 +898,9 @@ name = "rpds-py" version = "2026.5.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", ] sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } wheels = [ @@ -1053,6 +1070,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.26.7"