From 627c457b5b153b0680ebd67abf97b596569f80de Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Tue, 1 Sep 2026 13:36:51 +0800 Subject: [PATCH 1/2] feat(openviking-controlplane): add account data spaces Add account lifecycle support across the shared client, CLI, and MCP surfaces, thread account scopes through user and usage operations, and document the 0.3.0 contract. Validate identifiers locally, preserve legacy request bodies when account scope is omitted, and cover the new wire contracts and destructive confirmations. Co-authored-by: TRAE CLI --- .../README.md | 64 +- .../README_zh.md | 54 +- .../pyproject.toml | 2 +- .../skills/openviking-controlplane/SKILL.md | 71 +- .../mcp_server_openviking_controlplane/cli.py | 186 ++++- .../client.py | 147 +++- .../config.py | 12 + .../output.py | 37 +- .../server.py | 172 ++++- .../tests/test_account_contract.py | 693 ++++++++++++++++++ .../tests/test_output.py | 39 + .../tests/test_server_transport.py | 20 +- .../tests/test_user_contract.py | 9 +- .../uv.lock | 2 +- 14 files changed, 1427 insertions(+), 81 deletions(-) create mode 100644 server/mcp_server_openviking_controlplane/tests/test_account_contract.py diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index b3b44a9f..1de91697 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 11 collection lifecycle, billing, and user-management Actions: +Covers 14 collection lifecycle, billing, user-management, and data-space Actions: | Action | MCP tool | CLI command | |---|---|---| @@ -14,11 +14,14 @@ Covers 11 collection lifecycle, billing, and user-management Actions: | `UpdateOpenVikingCollection` | `update_collection` | `ov-cp update ` | | `DeleteOpenVikingCollection` | `delete_collection` ⚠️ | `ov-cp delete ` | | `GetOpenVikingUsage` | `get_usage` | `ov-cp usage ` | -| `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 ` | +| `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 ` | +| `ListOpenVikingAccounts` | `list_collection_accounts` | `ov-cp account list ` | +| `CreateOpenVikingAccount` | `create_collection_account` | `ov-cp account create ` | +| `DeleteOpenVikingAccount` | `delete_collection_account` ⚠️ | `ov-cp account 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 @@ -26,6 +29,16 @@ require the AgentPlan key to be **associated with the target library**. A user's data-plane key via `api-key --user-id `. Newly registered users always have role `user`; `user update` currently supports API Key rotation only. +The `account *` actions manage enterprise-tier data spaces: first-level isolation +boundaries for users, credentials, memories, resources, sessions, and skills. The +released backend uses AccountID `default` when `--account-id` is omitted, preserving +existing behavior. Creating one automatically adds its `default` admin user. Account +IDs are 1-64 characters using only ASCII letters, digits, `_`, `.`, `@`, or `-`; they +cannot start with `_`, equal `.` or `..`, or contain more than one `@`. The +per-library quota is backend-configured (currently 100 by default). Deleting a data +space cascades to everything inside it and is irreversible; `default` cannot be +deleted. Treat `CreateTime` from `account list` as an opaque backend timestamp string. + ## Endpoint The control-plane TopAPI is compiled into the OpenViking **data-plane cluster**; @@ -53,8 +66,8 @@ pluggable (`common/auth.py` → `BearerTokenAuth`); an AK/SK signer can be swapp later without touching the rest. > ⚠️ Write actions like `create` require the account to have **AgentPlan deduction -> activated**, otherwise they return `ProductUnordered`. Read-only actions -> (list/get/usage/delete) are not gated. +> activated**, otherwise they return `ProductUnordered`. Operations on an existing +> library can additionally require the AgentPlan key to be associated with that library. ### Configuration @@ -82,8 +95,13 @@ export AGENTPLAN_API_KEY=ark-xxxxxxxx uv run ov-cp list uv run ov-cp get uv run ov-cp usage +uv run ov-cp usage --account-id team-alpha +uv run ov-cp usage --user-id xiaohong # user in default data space +uv run ov-cp usage --account-id team-alpha --user-id xiaohong uv run ov-cp api-key uv run ov-cp api-key --user-id xiaohong +uv run ov-cp api-key --account-id team-alpha +uv run ov-cp api-key --account-id team-alpha --user-id xiaohong # create (consumes paid quota; always uses the AgentPlan model path and the # configured AgentPlan key; model source/parameters and image version are hidden) @@ -121,17 +139,22 @@ uv run ov-cp update --model-api-key ark-xxxxxxxx # manage users of an enterprise-tier library (key must be associated with it) uv run ov-cp user list -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 +uv run ov-cp user list --account-id team-alpha --role user --page 1 --limit 20 +uv run ov-cp user register xiaohong --account-id team-alpha +uv run ov-cp user update xiaohong --account-id team-alpha --regenerate-key +uv run ov-cp user delete xiaohong --account-id team-alpha --yes + +# manage data spaces (accounts) in an enterprise-tier library +uv run ov-cp account list --keyword team --page 1 --limit 20 +uv run ov-cp account create team-alpha +uv run ov-cp account delete team-alpha # prompts before cascading deletion # delete (irreversible) 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 +Rich views: tables for collection/user/data-space 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: @@ -143,10 +166,12 @@ 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; -for `volc_pay` it reports CNY only. +Library-wide `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. Account- or user-scoped usage omits +both library-wide estimates. `--user-id` may be used alone for the `default` account; +when `--account-id` is supplied, usage sends it as `OpenVikingAccountID` internally. 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` — @@ -168,7 +193,7 @@ gateway. Add to `.mcp.json`: "command": "uvx", "args": [ "--from", - "mcp-server-openviking-controlplane>=0.2.0", + "mcp-server-openviking-controlplane>=0.3.0", "mcp-server-openviking-controlplane" ], "env": { @@ -267,6 +292,7 @@ A Claude Code / agent skill that documents the `ov-cp` workflow lives at Symlink or copy it into your agent's skills directory (e.g. `~/.claude/skills/`) to let an agent drive the control plane. -> ⚠️ `create_collection` / `delete_collection` create/destroy **billable** resources and -> are exposed as MCP tools; their descriptions instruct the model to confirm with you -> first. Rely on your client's tool-permission prompt as the final gate. +> ⚠️ `create_collection` / `delete_collection` create/destroy **billable** resources, +> while `delete_collection_account` irreversibly destroys a data space and all of its +> contents. These are exposed as MCP tools; their descriptions instruct the model to +> confirm with you first. Rely on your client's tool-permission prompt as the final gate. diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index db78349a..4dddc794 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: +覆盖 14 个库生命周期、计费、用户管理与数据空间 Action: | Action | MCP tool | CLI 命令 | |---|---|---| @@ -14,17 +14,28 @@ OpenViking 控制面(topapi)的 MCP Server **与** CLI —— 用于管理 O | `UpdateOpenVikingCollection` | `update_collection` | `ov-cp update ` | | `DeleteOpenVikingCollection` | `delete_collection` ⚠️ | `ov-cp delete ` | | `GetOpenVikingUsage` | `get_usage` | `ov-cp usage ` | -| `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 ` | +| `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 ` | +| `ListOpenVikingAccounts` | `list_collection_accounts` | `ov-cp account list ` | +| `CreateOpenVikingAccount` | `create_collection_account` | `ov-cp account create ` | +| `DeleteOpenVikingAccount` | `delete_collection_account` ⚠️ | `ov-cp account delete ` | `user *` 系列管理企业版库的多用户,要求 AgentPlan key **与目标库已关联**。`user list` 返回的用户 `ApiKey` 是**掩码**,取指定用户的明文数据面 key 使用 `api-key --user-id `。新注册用户的角色固定为 `user`;`user update` 当前只支持重生 API Key。 +`account *` 系列管理企业版库的数据空间:它是用户、凭证、记忆、资源、会话与技能的 +一级隔离边界。已发布后端在省略 `--account-id` 时使用 AccountID `default`,因此存量用法 +保持不变。新建数据空间时会自动添加其 `default` 管理员用户。AccountID 长度为 1-64 个 +字符,只能包含 ASCII 字母、数字、`_`、`.`、`@`、`-`;不能以 `_` 开头,不能等于 `.` +或 `..`,且至多包含一个 `@`。单库配额由后端配置(当前默认 100)。删除数据空间会不可逆 +地级联删除其中所有内容;`default` 不可删除。将 `account list` 返回的 `CreateTime` 视为 +后端不透明时间戳字符串。 + ## 端点 控制面 TopAPI 接口已编译进 OpenViking **数据面集群**,每个 Action 由数据面网关在如下路径提供: @@ -49,7 +60,7 @@ Action 在 **path** 里(不走 `?Action=&Version=` query)。请求体是该 (`common/auth.py` → `BearerTokenAuth`),后续要换 AK/SK 签名时只需替换这一处。 > ⚠️ `create` 等写接口要求账号已**开通 AgentPlan 抵扣**,否则返回 `ProductUnordered`; -> 只读接口(list/get/usage/delete)不受此限。 +> 操作已有库时还可能要求 AgentPlan key 已与目标库关联。 ### 配置 @@ -76,8 +87,13 @@ export AGENTPLAN_API_KEY=ark-xxxxxxxx uv run ov-cp list uv run ov-cp get uv run ov-cp usage +uv run ov-cp usage --account-id team-alpha +uv run ov-cp usage --user-id xiaohong # default 数据空间内的用户 +uv run ov-cp usage --account-id team-alpha --user-id xiaohong uv run ov-cp api-key uv run ov-cp api-key --user-id xiaohong +uv run ov-cp api-key --account-id team-alpha +uv run ov-cp api-key --account-id team-alpha --user-id xiaohong # 建库(消耗付费配额;固定使用 AgentPlan 模型路径和已配置的 AgentPlan key, # 不开放模型来源、模型参数、模型鉴权与 OpenViking 镜像版本) @@ -110,17 +126,22 @@ uv run ov-cp update --model-api-key ark-xxxxxxxx # 管理企业版库的用户(key 需与该库已关联) uv run ov-cp user list -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 +uv run ov-cp user list --account-id team-alpha --role user --page 1 --limit 20 +uv run ov-cp user register xiaohong --account-id team-alpha +uv run ov-cp user update xiaohong --account-id team-alpha --regenerate-key +uv run ov-cp user delete xiaohong --account-id team-alpha --yes + +# 管理企业版库的数据空间(Account) +uv run ov-cp account list --keyword team --page 1 --limit 20 +uv run ov-cp account create team-alpha +uv run ov-cp account delete team-alpha # 级联删除前会要求确认 # 删库(不可逆) uv run ov-cp delete --yes ``` 默认的 `--output auto` 在 stdout 连接终端时使用 Rich 结构化视图: -库/用户列表显示为表格,`get`/`usage` 显示为分区详情卡片,写操作显示精简成功卡片, +库/用户/数据空间列表显示为表格,`get`/`usage` 显示为分区详情卡片,写操作显示精简成功卡片, 明文 API Key 则显示敏感信息警告。管道和重定向会自动保持标准 JSON: ```bash @@ -131,9 +152,11 @@ uv run ov-cp --output json-compact list uv run ov-cp --output pretty list # 强制终端视图 ``` -`usage` 保留后端原有的 `EstimatedCosts` 字段,同时新增 `EstimatedBilling`, +库级 `usage` 保留后端原有的 `EstimatedCosts` 字段,同时新增 `EstimatedBilling`, 明确费用为每小时 CNY 估值。AgentPlan 支付的库还会返回对应的 AFP 抵扣量和 -支付场景;`volc_pay` 只返回 CNY。 +支付场景;`volc_pay` 只返回 CNY。按数据空间或用户查询时会去掉这两个库级估值。 +`--user-id` 可单独使用,此时查询 `default` 数据空间;传入 `--account-id` 时,usage +内部使用 `OpenVikingAccountID` 字段发送该范围。 命令行参数优先于环境变量。端点默认指向公网网关;仅在测试时(如指向 port-forward)才用 `-e` / `VIKING_ENDPOINT` 覆盖:`uv run ov-cp -e http://localhost:18080 list`。 @@ -153,7 +176,7 @@ streamable HTTP 的方式挂在网关后面。`.mcp.json` 配置: "command": "uvx", "args": [ "--from", - "mcp-server-openviking-controlplane>=0.2.0", + "mcp-server-openviking-controlplane>=0.3.0", "mcp-server-openviking-controlplane" ], "env": { @@ -240,5 +263,6 @@ HTTP 传输下 AgentPlan ApiKey **按请求解析**,因此单个进程可以 需要 SSE 时:`mcp-server-openviking-controlplane --transport sse`。 -> ⚠️ `create_collection` / `delete_collection` 会创建/销毁**付费**资源,且已暴露为 MCP -> tool;其描述会要求模型先与你确认。最终拦截依赖客户端的工具授权弹窗。 +> ⚠️ `create_collection` / `delete_collection` 会创建/销毁**付费**资源; +> `delete_collection_account` 会不可逆地销毁数据空间及其全部内容。这些能力均已暴露为 +> MCP tool,其描述会要求模型先与你确认。最终拦截依赖客户端的工具授权弹窗。 diff --git a/server/mcp_server_openviking_controlplane/pyproject.toml b/server/mcp_server_openviking_controlplane/pyproject.toml index 5f909fcd..61d445df 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.2.0" +version = "0.3.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/skills/openviking-controlplane/SKILL.md b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md index fb47349d..8bcba380 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) 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. +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 and account data spaces of an enterprise-tier library (list / create / register / update / delete), account-scoped users / API keys / usage, 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 or accounts / 数据空间, 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`) @@ -38,8 +38,12 @@ 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 usage --account-id team-alpha +ov-cp usage --user-id xiaohong # user in account default +ov-cp usage --account-id team-alpha --user-id xiaohong 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 api-key --account-id team-alpha --user-id xiaohong 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 @@ -51,11 +55,23 @@ 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 + +# target a non-default data space with --account-id: +ov-cp user list --account-id team-alpha +ov-cp user register xiaohong --account-id team-alpha +ov-cp user update xiaohong --account-id team-alpha --regenerate-key +ov-cp user delete xiaohong --account-id team-alpha --yes + +# data spaces (accounts) of an enterprise-tier library: +ov-cp account list --keyword team --page 1 --limit 20 +ov-cp account create team-alpha +ov-cp account delete team-alpha # prompts before cascading deletion ``` 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. +success and does not contain the new key. Include `--account-id ` when +the user is outside account `default`. `update --model-api-key ` overwrites the library's AgentPlan MODEL credential; VLM and Embedding always share one key, and the library's other @@ -77,9 +93,43 @@ substitution remain safe. Use the global `--json`, `--output json-compact`, or 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. +Library-wide `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. +Account- or user-scoped usage omits both library-wide fields. `--user-id` may be +used alone for a user in account `default`; account scope is sent internally as +`OpenVikingAccountID`. + +## Data spaces (accounts) + +Treat an account as an enterprise-tier library's first-level isolation boundary +for users, credentials, memories, resources, sessions, and skills. Treat +`default` as the released backend's default AccountID, so omitting +`--account-id` preserves existing behavior. Expect a newly created data space to +contain an automatically created `default` admin user. + +Validate AccountID locally before sending a request; reject invalid input without +a backend request or cost. Apply all of these naming rules: + +- Require 1-64 characters. +- Allow only ASCII letters, digits, `_`, `.`, `@`, and `-`; reject spaces and Chinese characters. +- Reject an ID that starts with `_`. +- Reject `.` and `..`. +- Allow at most one `@`. + +Respect the backend-configured per-library account quota. Treat 100 as the current +default, not as a fixed limit. Treat `CreateTime` from `account list` as an opaque +backend timestamp string. Never delete account `default`. + +Use this confirmation workflow before deleting any other data space: + +1. First restate the target library ResourceID, exact AccountID, and full destruction scope: every user, credential, memory, resource, session, and skill in the data space. Ask the user to confirm that scope. +2. After that confirmation, require the user to repeat the exact AccountID. +3. Compare the repeated AccountID exactly with the target. Never infer an AccountID from a keyword or partial match in `account list`. +4. Only after both confirmations, run `ov-cp account delete --yes`. + +Never use `--yes` on the first deletion step. Stop if either confirmation is +missing or the repeated AccountID does not match exactly. ## Creating a collection @@ -145,14 +195,21 @@ The returned `ApiKey` is the library's **data-plane** key. Use it as ## Notes - Only `Authorization: Bearer` is accepted (no `X-API-Key`). -- `list` / `get` / `usage` are read-only. `delete` is destructive but, like those - reads, is not gated by AgentPlan; `create` and `api-key` are gated. +- `list` / `get` / `usage` are read-only; `delete` is destructive. Operations + on an existing library may require the AgentPlan key to be associated with it. - `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 --user-id `. +- Account operations also require the AgentPlan key to be associated with the target + library. +- `--account-id` applies to `api-key`, `usage`, and every `user *` command; omitting + it selects account `default`. `usage --user-id ` is valid without it. +- Scoped usage omits both `EstimatedCosts` and `EstimatedBilling`; `get_usage` maps + account scope to `OpenVikingAccountID` internally. Treat account-list + `CreateTime` as opaque. - 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 a481afbc..255669b5 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 @@ -5,8 +5,15 @@ import typer -from mcp_server_openviking_controlplane.client import ControlPlaneClient, ControlPlaneError +from mcp_server_openviking_controlplane.client import ( + ControlPlaneClient, + ControlPlaneError, + _normalize_optional_account_id, + validate_account_id, +) from mcp_server_openviking_controlplane.config import ( + ACCOUNT_ID_RULES, + DEFAULT_ACCOUNT_ID, build_config, parse_extra_headers, ) @@ -142,11 +149,33 @@ def get_cmd(ctx: typer.Context, resource_id: str = typer.Argument(..., help="Tar @app.command("usage") -def usage_cmd(ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID.")): - """Get overall usage / file counts of a collection.""" +def usage_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + account_id: Optional[str] = typer.Option( + None, + "--account-id", + help="Data space (account) scope; omit for whole-library usage, or the " + "default data space when --user-id is set.", + ), + user_id: Optional[str] = typer.Option( + None, + "--user-id", + help="User whose usage to query; may be used with or without --account-id.", + ), +): + """Get library-wide or account/user-scoped usage and file counts.""" client = _client(ctx) try: - _print(ctx, client.get_usage(resource_id), "usage") + _print( + ctx, + client.get_usage( + resource_id, + account_id=account_id, + user_id=user_id, + ), + "usage", + ) except Exception as e: raise _fail(e) @@ -158,13 +187,26 @@ def api_key_cmd( user_id: Optional[str] = typer.Option( None, "--user-id", - help="Target UserID; omit for the default user.", + help="Target UserID; omit for the selected data space's default user.", + ), + account_id: Optional[str] = typer.Option( + None, + "--account-id", + help="Data space (account) to operate in; omit for the default data space.", ), ): """Get a user's plaintext data-plane API Key.""" client = _client(ctx) try: - _print(ctx, client.get_user_access(resource_id, user_id=user_id), "api-key") + _print( + ctx, + client.get_user_access( + resource_id, + user_id=user_id, + account_id=account_id, + ), + "api-key", + ) except Exception as e: raise _fail(e) @@ -299,6 +341,11 @@ def update_cmd( def user_list_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), + account_id: Optional[str] = typer.Option( + None, + "--account-id", + help="Data space (account) to operate in; omit for the default data space.", + ), user_id: Optional[str] = typer.Option( None, "--user-id", @@ -323,6 +370,7 @@ def user_list_cmd( role=role, page=page, limit=limit, + account_id=account_id, ), "users", ) @@ -334,12 +382,21 @@ def user_list_cmd( 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)."), + user_id: str = typer.Argument(..., help="UserID for the new user (unique in data space)."), + account_id: Optional[str] = typer.Option( + None, + "--account-id", + help="Data space (account) to operate in; omit for the default data space.", + ), ): """Register a new regular user under a collection.""" client = _client(ctx) try: - _print(ctx, client.register_user(resource_id, user_id), "success") + _print( + ctx, + client.register_user(resource_id, user_id, account_id=account_id), + "success", + ) except Exception as e: raise _fail(e) @@ -349,6 +406,11 @@ def user_update_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), user_id: str = typer.Argument(..., help="Target UserID."), + account_id: Optional[str] = typer.Option( + None, + "--account-id", + help="Data space (account) to operate in; omit for the default data space.", + ), regenerate_key: bool = typer.Option( False, "--regenerate-key", @@ -370,6 +432,7 @@ def user_update_cmd( resource_id, user_id, regenerate_key=regenerate_key, + account_id=account_id, ), "success", ) @@ -382,17 +445,120 @@ def user_delete_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), user_id: str = typer.Argument(..., help="Target UserID."), + account_id: Optional[str] = typer.Option( + None, + "--account-id", + help="Data space (account) to operate in; omit for the default data space.", + ), 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) + try: + normalized_account_id = _normalize_optional_account_id(account_id) + except Exception as e: + raise _fail(e) + if not yes: + typer.confirm( + f"Delete user {user_id} from data space {normalized_account_id or 'default'} " + f"in collection {resource_id} (revokes its credential)?", + abort=True, + ) + try: + _print( + ctx, + client.delete_user( + resource_id, + user_id, + account_id=normalized_account_id, + ), + "success", + ) + except Exception as e: + raise _fail(e) + + +account_app = typer.Typer( + help=( + "Manage data spaces (accounts) within an enterprise-tier collection. " + "Omitting --account-id targets the default data space for user/API-key " + "commands; unscoped usage remains library-wide." + ), + no_args_is_help=True, +) +app.add_typer(account_app, name="account") + + +@account_app.command("list") +def account_list_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + keyword: Optional[str] = typer.Option( + None, + "--keyword", + help="Filter data spaces by AccountID substring.", + ), + page: int = typer.Option(1, min=1, help="Page number (1-based)."), + limit: int = typer.Option(20, min=1, max=200, help="Data spaces per page."), +): + """List data spaces under an enterprise-tier collection.""" + client = _client(ctx) + try: + _print( + ctx, + client.list_accounts( + resource_id, + keyword=keyword, + page=page, + limit=limit, + ), + "accounts", + ) + except Exception as e: + raise _fail(e) + + +@account_app.command("create") +def account_create_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + account_id: str = typer.Argument( + ..., + help=f"AccountID for the new data space. {ACCOUNT_ID_RULES}", + ), +): + """Create a data space; the backend also creates its default admin.""" + client = _client(ctx) + try: + _print(ctx, client.create_account(resource_id, account_id), "success") + except Exception as e: + raise _fail(e) + + +@account_app.command("delete") +def account_delete_cmd( + ctx: typer.Context, + resource_id: str = typer.Argument(..., help="Target library ResourceID."), + account_id: str = typer.Argument(..., help="AccountID of the data space to delete."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +): + """Delete a data space and everything isolated inside it (irreversible).""" + client = _client(ctx) + try: + validated_account_id = validate_account_id(account_id) + if validated_account_id == DEFAULT_ACCOUNT_ID: + raise ValueError("the default account cannot be deleted") + except Exception as e: + raise _fail(e) if not yes: typer.confirm( - f"Delete user {user_id} from collection {resource_id} (revokes its credential)?", + f"Irreversibly delete data space {validated_account_id} from collection " + f"{resource_id}? This destroys ALL of its users, credentials, " + "memories, resources, sessions and skills.", abort=True, ) try: - _print(ctx, client.delete_user(resource_id, user_id), "success") + _print(ctx, client.delete_account(resource_id, validated_account_id), "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 1e7abba7..c49a4356 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 @@ -7,6 +7,10 @@ from mcp_server_openviking_controlplane.common.auth import AuthProvider, BearerTokenAuth from mcp_server_openviking_controlplane.config import ( + ACCOUNT_ID_MAX_LENGTH, + ACCOUNT_ID_PATTERN, + ACCOUNT_ID_RULES, + DEFAULT_ACCOUNT_ID, DEFAULT_EMBEDDING_MODEL, DEFAULT_VLM_MODEL, PAY_TYPE_MAP, @@ -126,6 +130,48 @@ def build_payment_config( } +def validate_account_id(account_id: str, label: str = "account_id") -> str: + """Validate and return an OpenViking account (data-space) identifier.""" + reason: Optional[str] = None + if not isinstance(account_id, str): + reason = "must be a string" + elif not 1 <= len(account_id) <= ACCOUNT_ID_MAX_LENGTH: + reason = f"must be between 1 and {ACCOUNT_ID_MAX_LENGTH} characters" + elif ACCOUNT_ID_PATTERN.fullmatch(account_id) is None: + reason = "contains unsupported characters" + elif account_id.startswith("_"): + reason = "must not start with '_'" + elif account_id in {".", ".."}: + reason = "must not be '.' or '..'" + elif account_id.count("@") > 1: + reason = "may contain at most one '@'" + + if reason is not None: + raise ValueError( + f"invalid {label} {account_id!r}; {reason}. Rules: {ACCOUNT_ID_RULES}" + ) + return account_id + + +def _normalize_optional_account_id(account_id: Optional[str]) -> Optional[str]: + """Match the backend's optional AccountID normalization for user actions.""" + if account_id is None: + return None + if not isinstance(account_id, str): + return validate_account_id(account_id) + normalized = account_id.strip() + if not normalized: + return None + return validate_account_id(normalized) + + +def _validate_pagination(page: int, limit: int) -> None: + if page < 1: + raise ValueError("page must be >= 1") + if not 1 <= limit <= 200: + raise ValueError("limit must be between 1 and 200") + + class ControlPlaneError(RuntimeError): """Raised when the control plane returns an Error envelope or a non-200 status.""" @@ -485,10 +531,30 @@ def _replay_note(cls, blocks: Dict[str, Any], explicit_key: bool) -> str: def delete_collection(self, resource_id: str) -> Dict[str, Any]: return self._request("DeleteOpenVikingCollection", {"ResourceID": resource_id}) - def get_usage(self, resource_id: str) -> Dict[str, Any]: - result = self._request("GetOpenVikingUsage", {"ResourceID": resource_id}) + def get_usage( + self, + resource_id: str, + account_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + body: Dict[str, Any] = {"ResourceID": resource_id} + if account_id is not None: + # Usage uses the backend's historical field name; other account-aware + # actions serialize this identifier as AccountID. + body["OpenVikingAccountID"] = validate_account_id(account_id) + if user_id is not None: + # A UserID without an explicit account selects the default data space. + body["UserID"] = user_id + result = self._request("GetOpenVikingUsage", body) # AgentFileNum is not meaningful here; drop it from the returned usage. result.pop("AgentFileNum", None) + if account_id is not None or user_id is not None: + # Costs and collection metadata are library-wide. Attaching them to an + # account/user slice would be misleading, so scoped usage returns only + # scoped counters and avoids the extra collection request. + result.pop("EstimatedCosts", None) + result.pop("EstimatedBilling", None) + return result collection: Optional[Dict[str, Any]] = None try: collection = self.get_collection(resource_id) @@ -506,6 +572,7 @@ def get_user_access( self, resource_id: str, user_id: Optional[str] = None, + account_id: Optional[str] = None, ) -> Dict[str, Any]: # On the data-plane cluster the api-key action is registered as # GetOpenVikingCollectionUserAccess (the console proxy's @@ -516,6 +583,9 @@ def get_user_access( body: Dict[str, Any] = {"ResourceID": resource_id} if user_id is not None: body["UserID"] = user_id + normalized_account_id = _normalize_optional_account_id(account_id) + if normalized_account_id is not None: + body["AccountID"] = normalized_account_id return self._request("GetOpenVikingCollectionUserAccess", body) # --- User management (enterprise-tier libraries: multi-user) ------------- @@ -530,12 +600,10 @@ def list_collection_users( role: Optional[str] = None, page: int = 1, limit: int = 20, + account_id: Optional[str] = None, ) -> Dict[str, Any]: # ListOpenVikingCollectionUser: users under the library (ApiKey masked). - if page < 1: - raise ValueError("page must be >= 1") - if not 1 <= limit <= 200: - raise ValueError("limit must be between 1 and 200") + _validate_pagination(page, limit) body: Dict[str, Any] = { "ResourceID": resource_id, "Page": page, @@ -545,17 +613,24 @@ def list_collection_users( body["UserID"] = user_id if role is not None: body["Role"] = role + normalized_account_id = _normalize_optional_account_id(account_id) + if normalized_account_id is not None: + body["AccountID"] = normalized_account_id return self._request("ListOpenVikingCollectionUser", body) def register_user( self, resource_id: str, user_id: str, + account_id: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: # 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} + normalized_account_id = _normalize_optional_account_id(account_id) + if normalized_account_id is not None: + body["AccountID"] = normalized_account_id if extra: body.update(extra) return self._request("RegisterOpenVikingUser", body) @@ -565,6 +640,7 @@ def update_user( resource_id: str, user_id: str, regenerate_key: bool = False, + account_id: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: # UpdateOpenVikingUser only supports rotating the user's ApiKey. @@ -577,14 +653,69 @@ def update_user( "UserID": user_id, "RegenerateKey": True, } + normalized_account_id = _normalize_optional_account_id(account_id) + if normalized_account_id is not None: + body["AccountID"] = normalized_account_id if extra: body.update(extra) return self._request("UpdateOpenVikingUser", body) - def delete_user(self, resource_id: str, user_id: str) -> Dict[str, Any]: + def delete_user( + self, + resource_id: str, + user_id: str, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: # DeleteOpenVikingUser: remove a user from the library. + body: Dict[str, Any] = {"ResourceID": resource_id, "UserID": user_id} + normalized_account_id = _normalize_optional_account_id(account_id) + if normalized_account_id is not None: + body["AccountID"] = normalized_account_id + return self._request("DeleteOpenVikingUser", body) + + # --- Account management (enterprise-tier data spaces) ------------------- + + def create_account( + self, + resource_id: str, + account_id: str, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + body: Dict[str, Any] = { + "ResourceID": resource_id, + "AccountID": validate_account_id(account_id), + } + if extra: + body.update(extra) + return self._request("CreateOpenVikingAccount", body) + + def list_accounts( + self, + resource_id: str, + keyword: Optional[str] = None, + page: int = 1, + limit: int = 20, + ) -> Dict[str, Any]: + _validate_pagination(page, limit) + body: Dict[str, Any] = { + "ResourceID": resource_id, + "Page": page, + "Limit": limit, + } + # Keyword is a substring filter, not an account identifier. + if keyword is not None: + body["Keyword"] = keyword + return self._request("ListOpenVikingAccounts", body) + + def delete_account(self, resource_id: str, account_id: str) -> Dict[str, Any]: + account_id = validate_account_id(account_id) + # Mirror the backend guard before a caller crosses a destructive-confirmation + # boundary only to have the reserved default data space rejected remotely. + if account_id == DEFAULT_ACCOUNT_ID: + raise ValueError("the default account cannot be deleted") return self._request( - "DeleteOpenVikingUser", {"ResourceID": resource_id, "UserID": user_id} + "DeleteOpenVikingAccount", + {"ResourceID": resource_id, "AccountID": account_id}, ) 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 9523dc71..ffe28cea 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,5 +1,6 @@ import logging import os +import re from dataclasses import dataclass, field from typing import Dict, Optional @@ -32,6 +33,17 @@ # tier; "enterprise" is the higher-capacity, enterprise-billed tier. VERSION_CHOICES = ("developer", "enterprise") +# Account (data-space) identifiers are shared by account lifecycle operations +# and account-aware user/usage operations. +DEFAULT_ACCOUNT_ID = "default" +ACCOUNT_ID_MAX_LENGTH = 64 +ACCOUNT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.@-]+$") +ACCOUNT_ID_RULES = ( + "must be 1-64 characters using only ASCII letters, digits, '_', '.', '@', or '-'; " + "must not start with '_'; must not be '.' or '..'; and may contain at most " + "one '@'" +) + # 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); 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 index 51353f64..be0b0688 100644 --- 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 @@ -56,6 +56,8 @@ def _pretty_renderable(result: Any, view: str) -> Any: return _collections_table(result.get("Collections")) if view == "users" and isinstance(result, dict): return _users_table(result.get("UserList"), result.get("Total")) + if view == "accounts" and isinstance(result, dict): + return _accounts_table(result.get("AccountList"), result.get("Total")) if view == "usage" and isinstance(result, dict): return _usage_panel(result) if view == "collection" and isinstance(result, dict): @@ -120,6 +122,31 @@ def _users_table(rows: Any, total: Any) -> Any: return table +def _accounts_table(rows: Any, total: Any) -> Any: + if not isinstance(rows, list): + return _generic_renderable({"AccountList": rows, "Total": total}) + count = total if total is not None else len(rows) + table = Table( + title=f"Data Spaces ({count})", + box=box.ROUNDED, + header_style="bold cyan", + ) + table.add_column("Account ID", style="bold") + table.add_column("Users") + table.add_column("Created") + table.add_column("Default") + for row in rows: + if not isinstance(row, dict): + continue + table.add_row( + _text(row.get("AccountID")), + _text(row.get("UserCount")), + _text(row.get("CreateTime")), + _value(row.get("IsDefault"), "IsDefault"), + ) + return table + + def _usage_panel(result: Dict[str, Any]) -> Panel: files = Table.grid(padding=(0, 2)) files.add_column(style="dim", no_wrap=True) @@ -145,11 +172,19 @@ def _usage_panel(result: Dict[str, Any]) -> Panel: "CNY equivalent", f"¥{_text(billing.get('CNY'))} / {_period(billing)}", ) - else: + elif "EstimatedCosts" in result: billing_table.add_row( "Estimated cost", f"¥{_text(result.get('EstimatedCosts'))} / hour", ) + else: + billing_table.add_row( + "Estimated cost", + Text( + "— (reported for the whole library, not per data space)", + style="dim", + ), + ) content = Group( Text("Context Files", style="bold cyan"), 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 b6ee8cdf..18e20cbe 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 @@ -11,6 +11,7 @@ ControlPlaneError, build_client, ) +from mcp_server_openviking_controlplane.config import ACCOUNT_ID_RULES logger = logging.getLogger(__name__) logging.basicConfig( @@ -128,22 +129,33 @@ def get_collection( @mcp.tool() def get_usage( - resource_id: str, ctx: Optional[Context] = None + resource_id: str, + account_id: Optional[str] = None, + user_id: Optional[str] = None, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: - """Get overall usage / file counts for one OpenViking collection by ResourceID. + """Get library-wide or account/user-scoped usage and file counts. Args: resource_id: target library ResourceID. + account_id: optional data space (account) scope; omit for whole-library + usage, or the default data space when user_id is supplied. + user_id: optional user scope; supported with or without account_id. Returns: {"CurContextFileNum", "ResourcesFileNum", "UserFileNum", "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. + For library-wide queries, EstimatedBilling adds CNY / hour plus PayType + and, for AgentPlan payment, the equivalent AFP / hour. Scoped responses + (when account_id and/or user_id is set) omit the library-wide + EstimatedCosts and EstimatedBilling because they would be misleading. """ try: - return get_client(ctx).get_usage(resource_id) + return get_client(ctx).get_usage( + resource_id, + account_id=account_id, + user_id=user_id, + ) except Exception as e: logger.error(f"get_usage failed: {e}") return _err(e) @@ -153,25 +165,32 @@ def get_usage( def get_collection_api_key( resource_id: str, user_id: Optional[str] = None, + account_id: Optional[str] = None, ctx: Optional[Context] = None, ) -> Dict[str, Any]: """Get one user's plaintext data-plane API Key. 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. + returns the selected data space's default-user credential; enterprise libraries + can select a specific user. You can only query libraries associated with your + control-plane credential; there is no cross-owner / 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. + user_id: optional target UserID; omit for the selected data space's + default user. + account_id: optional data space (account); omit for the default data space. Returns: {"UserID", "Role", "ApiKey"} """ try: - return get_client(ctx).get_user_access(resource_id, user_id=user_id) + return get_client(ctx).get_user_access( + resource_id, + user_id=user_id, + account_id=account_id, + ) except Exception as e: logger.error(f"get_collection_api_key failed: {e}") return _err(e) @@ -307,6 +326,7 @@ def list_collection_users( role: Optional[str] = None, page: int = 1, limit: int = 20, + account_id: Optional[str] = None, ctx: Optional[Context] = None, ) -> Dict[str, Any]: """List the users registered under one OpenViking collection. @@ -321,6 +341,7 @@ def list_collection_users( 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. + account_id: optional data space (account); omit for the default data space. Returns: {"UserList": [ {"UserID", "Role", "ApiKey" (masked)} ], "Total": N} @@ -332,6 +353,7 @@ def list_collection_users( role=role, page=page, limit=limit, + account_id=account_id, ) except Exception as e: logger.error(f"list_collection_users failed: {e}") @@ -340,7 +362,10 @@ def list_collection_users( @mcp.tool() def register_collection_user( - resource_id: str, user_id: str, ctx: Optional[Context] = None + resource_id: str, + user_id: str, + account_id: Optional[str] = None, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: """Register a NEW user under an OpenViking collection (RegisterOpenVikingUser). @@ -350,13 +375,18 @@ def register_collection_user( Args: resource_id: target library ResourceID. - user_id: the UserID for the new user (unique within the library). + user_id: the UserID for the new user (unique within the data space). + account_id: optional data space (account); omit for the default data space. Returns: {"Success": true} """ try: - return get_client(ctx).register_user(resource_id, user_id) + return get_client(ctx).register_user( + resource_id, + user_id, + account_id=account_id, + ) except Exception as e: logger.error(f"register_collection_user failed: {e}") return _err(e) @@ -367,6 +397,7 @@ def update_collection_user( resource_id: str, user_id: str, regenerate_key: bool, + account_id: Optional[str] = None, ctx: Optional[Context] = None, ) -> Dict[str, Any]: """Update a user under an OpenViking collection (currently API Key rotation). @@ -379,6 +410,7 @@ def update_collection_user( resource_id: target library ResourceID. user_id: the UserID to update. regenerate_key: true to rotate the user's data-plane API Key. + account_id: optional data space (account); omit for the default data space. Returns: {"Success": true} @@ -388,6 +420,7 @@ def update_collection_user( resource_id, user_id, regenerate_key=regenerate_key, + account_id=account_id, ) except Exception as e: logger.error(f"update_collection_user failed: {e}") @@ -396,7 +429,10 @@ def update_collection_user( @mcp.tool() def delete_collection_user( - resource_id: str, user_id: str, ctx: Optional[Context] = None + resource_id: str, + user_id: str, + account_id: Optional[str] = None, + ctx: Optional[Context] = None, ) -> Dict[str, Any]: """⚠️ Delete a user from an OpenViking collection (DeleteOpenVikingUser). @@ -406,17 +442,119 @@ def delete_collection_user( Args: resource_id: target library ResourceID. user_id: the UserID to delete. + account_id: optional data space (account); omit for the default data space. Returns: {"Success": true} """ try: - return get_client(ctx).delete_user(resource_id, user_id) + return get_client(ctx).delete_user( + resource_id, + user_id, + account_id=account_id, + ) except Exception as e: logger.error(f"delete_collection_user failed: {e}") return _err(e) +@mcp.tool() +def list_collection_accounts( + resource_id: str, + keyword: Optional[str] = None, + page: int = 1, + limit: int = 20, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """List data spaces (accounts) under an enterprise-tier collection. + + Args: + resource_id: target library ResourceID. + keyword: optional AccountID substring filter. + page: 1-based page number; defaults to 1. + limit: data spaces per page, 1 to 200; defaults to 20. + + Returns: + {"AccountList": [{"AccountID", "UserCount", "CreateTime", + "IsDefault"}], "Total": N} + """ + try: + return get_client(ctx).list_accounts( + resource_id, + keyword=keyword, + page=page, + limit=limit, + ) + except Exception as e: + logger.error(f"list_collection_accounts failed: {e}") + return _err(e) + + +@mcp.tool( + description=( + "Create a data space (account), an isolation boundary within a library. " + "CONFIRM WITH THE USER before calling. The backend creates a default " + "admin in the new data space; the per-library account limit is " + f"backend-configured. AccountID {ACCOUNT_ID_RULES}." + ) +) +def create_collection_account( + resource_id: str, + account_id: str, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """Create a data space (account), an isolation boundary within a library. + + CONFIRM WITH THE USER before calling. This enterprise-tier capability creates + a separate boundary for users, credentials, memories, resources, sessions, + and skills. The backend automatically creates a ``default`` admin inside the + new data space. The per-library account limit is backend-configured. + + AccountID is validated locally: it must be 1-64 characters using only + letters, digits, '_', '.', '@', or '-'; must not start with '_'; must not + be '.' or '..'; and may contain at most one '@'. + + Args: + resource_id: target library ResourceID. + account_id: AccountID for the new data space. + + Returns: + {"Success": true, "AccountID": "..."} + """ + try: + return get_client(ctx).create_account(resource_id, account_id) + except Exception as e: + logger.error(f"create_collection_account failed: {e}") + return _err(e) + + +@mcp.tool() +def delete_collection_account( + resource_id: str, + account_id: str, + ctx: Optional[Context] = None, +) -> Dict[str, Any]: + """⚠️ IRREVERSIBLY delete a data space and all isolated data inside it. + + CONFIRM WITH THE USER before calling. Deletion cascades through ALL users, + credentials, memories, resources, sessions, and skills in the data space. + The ``default`` data space cannot be deleted; both the client and backend + reject it. + + Args: + resource_id: target library ResourceID. + account_id: AccountID of the data space to delete. + + Returns: + {"Success": true} + """ + try: + return get_client(ctx).delete_account(resource_id, account_id) + except Exception as e: + logger.error(f"delete_collection_account failed: {e}") + return _err(e) + + @mcp.tool() def delete_collection( resource_id: str, ctx: Optional[Context] = None diff --git a/server/mcp_server_openviking_controlplane/tests/test_account_contract.py b/server/mcp_server_openviking_controlplane/tests/test_account_contract.py new file mode 100644 index 00000000..05a10ca1 --- /dev/null +++ b/server/mcp_server_openviking_controlplane/tests/test_account_contract.py @@ -0,0 +1,693 @@ +import inspect +import unittest +from unittest.mock import patch + +from typer.testing import CliRunner + +from mcp_server_openviking_controlplane import cli, server +from mcp_server_openviking_controlplane.cli import app +from mcp_server_openviking_controlplane.client import ( + ControlPlaneClient, + ControlPlaneError, + validate_account_id, +) +from mcp_server_openviking_controlplane.config import ControlPlaneConfig + + +class AccountClientContractTest(unittest.TestCase): + def setUp(self): + self.client = ControlPlaneClient(ControlPlaneConfig(api_key="ark-test")) + + def test_create_account_sends_exact_wire_body(self): + with patch.object( + self.client, + "_request", + return_value={"Success": True, "AccountID": "team.alpha"}, + ) as request: + result = self.client.create_account( + "ov-example", + "team.alpha", + extra={"TraceTag": "test"}, + ) + + self.assertEqual(result["AccountID"], "team.alpha") + request.assert_called_once_with( + "CreateOpenVikingAccount", + { + "ResourceID": "ov-example", + "AccountID": "team.alpha", + "TraceTag": "test", + }, + ) + + def test_list_accounts_omits_keyword_by_default(self): + with patch.object( + self.client, + "_request", + return_value={"AccountList": [], "Total": 0}, + ) as request: + self.client.list_accounts("ov-example") + + request.assert_called_once_with( + "ListOpenVikingAccounts", + {"ResourceID": "ov-example", "Page": 1, "Limit": 20}, + ) + + def test_list_accounts_forwards_keyword_and_pagination(self): + with patch.object( + self.client, + "_request", + return_value={"AccountList": [], "Total": 0}, + ) as request: + self.client.list_accounts( + "ov-example", + keyword="team alpha", + page=2, + limit=10, + ) + + request.assert_called_once_with( + "ListOpenVikingAccounts", + { + "ResourceID": "ov-example", + "Keyword": "team alpha", + "Page": 2, + "Limit": 10, + }, + ) + + def test_list_accounts_validates_pagination_locally(self): + for kwargs, message in ( + ({"page": 0}, "page must be >= 1"), + ({"limit": 0}, "limit must be between 1 and 200"), + ({"limit": 201}, "limit must be between 1 and 200"), + ): + with self.subTest(kwargs=kwargs): + with self.assertRaisesRegex(ValueError, message): + self.client.list_accounts("ov-example", **kwargs) + + def test_delete_account_rejects_default_before_request(self): + with patch.object(self.client, "_request") as request: + with self.assertRaisesRegex(ValueError, "default.*cannot be deleted"): + self.client.delete_account("ov-example", "default") + + request.assert_not_called() + + def test_delete_account_sends_exact_wire_body(self): + with patch.object( + self.client, + "_request", + return_value={"Success": True}, + ) as request: + self.client.delete_account("ov-example", "team-alpha") + + request.assert_called_once_with( + "DeleteOpenVikingAccount", + {"ResourceID": "ov-example", "AccountID": "team-alpha"}, + ) + + def test_account_id_validation_rejects_invalid_values(self): + invalid_values = ( + None, + 7, + "", + "a" * 65, + "team alpha", + "中文", + "_private", + ".", + "..", + "a@b@c", + ) + for value in invalid_values: + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "Rules:"): + validate_account_id(value) + + def test_account_id_validation_accepts_boundaries_and_supported_characters(self): + for value in ("a", "a" * 64, "team.alpha-1_x", "user@corp"): + with self.subTest(value=value): + self.assertEqual(validate_account_id(value), value) + + def test_existing_user_actions_omit_account_id_for_legacy_wire_compatibility(self): + calls = ( + ( + lambda: self.client.get_user_access("ov-example"), + "GetOpenVikingCollectionUserAccess", + {"ResourceID": "ov-example"}, + ), + ( + lambda: self.client.list_collection_users("ov-example"), + "ListOpenVikingCollectionUser", + {"ResourceID": "ov-example", "Page": 1, "Limit": 20}, + ), + ( + lambda: self.client.register_user("ov-example", "alice"), + "RegisterOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice"}, + ), + ( + lambda: self.client.update_user("ov-example", "alice", True), + "UpdateOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice", "RegenerateKey": True}, + ), + ( + lambda: self.client.delete_user("ov-example", "alice"), + "DeleteOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice"}, + ), + ) + for call, action, body in calls: + with self.subTest(action=action): + with patch.object(self.client, "_request", return_value={}) as request: + call() + request.assert_called_once_with(action, body) + + def test_existing_user_actions_forward_validated_account_id(self): + calls = ( + ( + lambda: self.client.get_user_access("ov-example", account_id="team"), + "GetOpenVikingCollectionUserAccess", + {"ResourceID": "ov-example", "AccountID": "team"}, + ), + ( + lambda: self.client.list_collection_users( + "ov-example", account_id="team" + ), + "ListOpenVikingCollectionUser", + { + "ResourceID": "ov-example", + "Page": 1, + "Limit": 20, + "AccountID": "team", + }, + ), + ( + lambda: self.client.register_user( + "ov-example", "alice", account_id="team" + ), + "RegisterOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice", "AccountID": "team"}, + ), + ( + lambda: self.client.update_user( + "ov-example", "alice", True, account_id="team" + ), + "UpdateOpenVikingUser", + { + "ResourceID": "ov-example", + "UserID": "alice", + "RegenerateKey": True, + "AccountID": "team", + }, + ), + ( + lambda: self.client.delete_user( + "ov-example", "alice", account_id="team" + ), + "DeleteOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice", "AccountID": "team"}, + ), + ) + for call, action, body in calls: + with self.subTest(action=action): + with patch.object(self.client, "_request", return_value={}) as request: + call() + request.assert_called_once_with(action, body) + + def test_existing_user_actions_normalize_optional_account_id(self): + calls = ( + lambda value: self.client.get_user_access("ov-example", account_id=value), + lambda value: self.client.list_collection_users( + "ov-example", account_id=value + ), + lambda value: self.client.register_user( + "ov-example", "alice", account_id=value + ), + lambda value: self.client.update_user( + "ov-example", "alice", True, account_id=value + ), + lambda value: self.client.delete_user( + "ov-example", "alice", account_id=value + ), + ) + for call in calls: + with self.subTest(call=call, value="blank"): + with patch.object(self.client, "_request", return_value={}) as request: + call(" ") + self.assertNotIn("AccountID", request.call_args.args[1]) + with self.subTest(call=call, value="trimmed"): + with patch.object(self.client, "_request", return_value={}) as request: + call(" team ") + self.assertEqual(request.call_args.args[1]["AccountID"], "team") + + def test_existing_actions_reject_invalid_account_id_before_request(self): + calls = ( + lambda: self.client.get_user_access("ov-example", account_id="bad value"), + lambda: self.client.get_user_access("ov-example", account_id=7), + lambda: self.client.list_collection_users( + "ov-example", account_id="bad value" + ), + lambda: self.client.register_user( + "ov-example", "alice", account_id="bad value" + ), + lambda: self.client.update_user( + "ov-example", "alice", True, account_id="bad value" + ), + lambda: self.client.delete_user( + "ov-example", "alice", account_id="bad value" + ), + lambda: self.client.get_usage("ov-example", account_id="bad value"), + ) + with patch.object(self.client, "_request") as request: + for call in calls: + with self.subTest(call=call): + with self.assertRaisesRegex(ValueError, "Rules:"): + call() + request.assert_not_called() + + def test_scoped_usage_uses_backend_field_and_removes_library_billing(self): + backend_result = { + "CurContextFileNum": 3, + "AgentFileNum": 9, + "EstimatedCosts": "0.05", + "EstimatedBilling": {"CNY": "0.05"}, + } + with patch.object( + self.client, "_request", return_value=backend_result + ) as request: + result = self.client.get_usage( + "ov-example", + account_id="team", + user_id="alice", + ) + + request.assert_called_once_with( + "GetOpenVikingUsage", + { + "ResourceID": "ov-example", + "OpenVikingAccountID": "team", + "UserID": "alice", + }, + ) + self.assertEqual(result, {"CurContextFileNum": 3}) + + def test_usage_allows_user_only_scope_in_default_account(self): + with patch.object( + self.client, + "_request", + return_value={"CurContextFileNum": 1, "EstimatedCosts": "0.05"}, + ) as request: + result = self.client.get_usage("ov-example", user_id="alice") + + request.assert_called_once_with( + "GetOpenVikingUsage", + {"ResourceID": "ov-example", "UserID": "alice"}, + ) + self.assertNotIn("EstimatedCosts", result) + + def test_unscoped_usage_preserves_legacy_wire_body_and_enrichment(self): + with patch.object( + self.client, + "_request", + side_effect=[ + {"AgentFileNum": 9, "EstimatedCosts": "0.05"}, + {}, + ], + ) as request: + result = self.client.get_usage("ov-example") + + self.assertEqual( + request.call_args_list[0].args, + ("GetOpenVikingUsage", {"ResourceID": "ov-example"}), + ) + self.assertNotIn("AgentFileNum", result) + self.assertEqual(result["EstimatedBilling"]["CNY"], "0.05") + + +class AccountCliContractTest(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.prefix = ["--api-key", "ark-test", "--json"] + + def test_account_list_forwards_filters_and_pagination(self): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.list_accounts", + return_value={"AccountList": [], "Total": 0}, + ) as list_accounts: + result = self.runner.invoke( + app, + self.prefix + + [ + "account", + "list", + "ov-example", + "--keyword", + "team", + "--page", + "2", + "--limit", + "10", + ], + ) + + self.assertEqual(result.exit_code, 0) + list_accounts.assert_called_once_with( + "ov-example", keyword="team", page=2, limit=10 + ) + + def test_account_create_forwards_identifiers(self): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.create_account", + return_value={"Success": True, "AccountID": "team"}, + ) as create_account: + result = self.runner.invoke( + app, + self.prefix + ["account", "create", "ov-example", "team"], + ) + + self.assertEqual(result.exit_code, 0) + create_account.assert_called_once_with("ov-example", "team") + + def test_account_create_reports_validation_error_without_traceback(self): + result = self.runner.invoke( + app, + self.prefix + ["account", "create", "ov-example", "team alpha"], + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn("Rules:", result.output) + self.assertNotIn("Traceback", result.output) + + def test_account_delete_confirmation_can_abort_without_calling_backend(self): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.delete_account" + ) as delete_account: + result = self.runner.invoke( + app, + self.prefix + ["account", "delete", "ov-example", "team"], + input="n\n", + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn("Aborted", result.output) + delete_account.assert_not_called() + + def test_account_delete_rejects_default_before_prompt_or_backend_call(self): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.delete_account" + ) as delete_account: + result = self.runner.invoke( + app, + self.prefix + ["account", "delete", "ov-example", "default"], + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn("default account cannot be deleted", result.output) + self.assertNotIn("[y/N]", result.output) + delete_account.assert_not_called() + + def test_account_delete_confirmation_and_yes_flag_call_backend(self): + for suffix, input_text in (([], "y\n"), (["--yes"], None)): + with self.subTest(suffix=suffix): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.delete_account", + return_value={"Success": True}, + ) as delete_account: + result = self.runner.invoke( + app, + self.prefix + + ["account", "delete", "ov-example", "team"] + + suffix, + input=input_text, + ) + + self.assertEqual(result.exit_code, 0) + delete_account.assert_called_once_with("ov-example", "team") + + def test_user_delete_confirms_and_forwards_normalized_account_id(self): + for raw_account_id, expected_account_id in ((" team ", "team"), (" ", None)): + with self.subTest(raw_account_id=raw_account_id): + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.delete_user", + return_value={"Success": True}, + ) as delete_user: + result = self.runner.invoke( + app, + self.prefix + + [ + "user", + "delete", + "ov-example", + "alice", + "--account-id", + raw_account_id, + ], + input="n\n", + ) + + self.assertEqual(result.exit_code, 1) + self.assertIn( + f"data space {expected_account_id or 'default'}", result.output + ) + delete_user.assert_not_called() + + with patch( + "mcp_server_openviking_controlplane.cli.ControlPlaneClient.delete_user", + return_value={"Success": True}, + ) as delete_user: + result = self.runner.invoke( + app, + self.prefix + + [ + "user", + "delete", + "ov-example", + "alice", + "--account-id", + raw_account_id, + "--yes", + ], + ) + + self.assertEqual(result.exit_code, 0) + delete_user.assert_called_once_with( + "ov-example", "alice", account_id=expected_account_id + ) + + def test_scoped_existing_commands_forward_account_and_user(self): + cases = ( + ( + "get_user_access", + ["api-key", "ov-example", "--account-id", "team", "--user-id", "alice"], + ("ov-example",), + {"user_id": "alice", "account_id": "team"}, + {"ApiKey": "plain"}, + ), + ( + "get_usage", + ["usage", "ov-example", "--account-id", "team", "--user-id", "alice"], + ("ov-example",), + {"account_id": "team", "user_id": "alice"}, + {"CurContextFileNum": 1}, + ), + ( + "list_collection_users", + ["user", "list", "ov-example", "--account-id", "team"], + ("ov-example",), + { + "user_id": None, + "role": None, + "page": 1, + "limit": 20, + "account_id": "team", + }, + {"UserList": [], "Total": 0}, + ), + ( + "register_user", + ["user", "register", "ov-example", "alice", "--account-id", "team"], + ("ov-example", "alice"), + {"account_id": "team"}, + {"Success": True}, + ), + ( + "update_user", + [ + "user", + "update", + "ov-example", + "alice", + "--regenerate-key", + "--account-id", + "team", + ], + ("ov-example", "alice"), + {"regenerate_key": True, "account_id": "team"}, + {"Success": True}, + ), + ( + "delete_user", + [ + "user", + "delete", + "ov-example", + "alice", + "--account-id", + "team", + "--yes", + ], + ("ov-example", "alice"), + {"account_id": "team"}, + {"Success": True}, + ), + ) + for method, argv, args, kwargs, response in cases: + with self.subTest(method=method): + with patch( + f"mcp_server_openviking_controlplane.cli.ControlPlaneClient.{method}", + return_value=response, + ) as call: + result = self.runner.invoke(app, self.prefix + argv) + self.assertEqual(result.exit_code, 0, result.output) + call.assert_called_once_with(*args, **kwargs) + + +class AccountMcpContractTest(unittest.TestCase): + def test_account_tools_forward_all_arguments(self): + with patch.object(server, "get_client") as get_client: + client = get_client.return_value + client.list_accounts.return_value = {"AccountList": [], "Total": 0} + client.create_account.return_value = {"Success": True} + client.delete_account.return_value = {"Success": True} + + server.list_collection_accounts( + "ov-example", keyword="team", page=2, limit=10 + ) + server.create_collection_account("ov-example", "team") + server.delete_collection_account("ov-example", "team") + + client.list_accounts.assert_called_once_with( + "ov-example", keyword="team", page=2, limit=10 + ) + client.create_account.assert_called_once_with("ov-example", "team") + client.delete_account.assert_called_once_with("ov-example", "team") + + def test_existing_tools_forward_scope(self): + with patch.object(server, "get_client") as get_client: + client = get_client.return_value + server.get_usage("ov-example", account_id="team", user_id="alice") + server.get_collection_api_key( + "ov-example", user_id="alice", account_id="team" + ) + server.list_collection_users( + "ov-example", account_id="team", page=2, limit=10 + ) + server.register_collection_user("ov-example", "alice", account_id="team") + server.update_collection_user( + "ov-example", "alice", regenerate_key=True, account_id="team" + ) + server.delete_collection_user("ov-example", "alice", account_id="team") + + client.get_usage.assert_called_once_with( + "ov-example", account_id="team", user_id="alice" + ) + client.get_user_access.assert_called_once_with( + "ov-example", user_id="alice", account_id="team" + ) + client.list_collection_users.assert_called_once_with( + "ov-example", + user_id=None, + role=None, + page=2, + limit=10, + account_id="team", + ) + client.register_user.assert_called_once_with( + "ov-example", "alice", account_id="team" + ) + client.update_user.assert_called_once_with( + "ov-example", "alice", regenerate_key=True, account_id="team" + ) + client.delete_user.assert_called_once_with( + "ov-example", "alice", account_id="team" + ) + + def test_account_tools_return_structured_errors(self): + cases = ( + ( + "list_accounts", + lambda: server.list_collection_accounts("ov-example"), + ControlPlaneError("Denied", "not allowed", "req-1"), + { + "error": { + "code": "Denied", + "message": "not allowed", + "request_id": "req-1", + } + }, + ), + ( + "create_account", + lambda: server.create_collection_account("ov-example", "bad value"), + ValueError("invalid account_id"), + {"error": {"message": "invalid account_id"}}, + ), + ) + for method, call, error, expected in cases: + with self.subTest(method=method): + with patch.object(server, "get_client") as get_client: + getattr(get_client.return_value, method).side_effect = error + self.assertEqual(call(), expected) + + def test_public_signatures_expose_scope_and_keep_context_last(self): + client_methods = ( + "get_usage", + "get_user_access", + "list_collection_users", + "register_user", + "update_user", + "delete_user", + ) + for name in client_methods: + with self.subTest(layer="client", name=name): + params = inspect.signature(getattr(ControlPlaneClient, name)).parameters + self.assertIn("account_id", params) + self.assertIn( + "user_id", inspect.signature(ControlPlaneClient.get_usage).parameters + ) + + cli_commands = ( + "usage_cmd", + "api_key_cmd", + "user_list_cmd", + "user_register_cmd", + "user_update_cmd", + "user_delete_cmd", + ) + for name in cli_commands: + with self.subTest(layer="cli", name=name): + params = inspect.signature(getattr(cli, name)).parameters + self.assertIn("account_id", params) + self.assertIn("user_id", inspect.signature(cli.usage_cmd).parameters) + + server_tools = ( + "get_usage", + "get_collection_api_key", + "list_collection_users", + "register_collection_user", + "update_collection_user", + "delete_collection_user", + "list_collection_accounts", + "create_collection_account", + "delete_collection_account", + ) + for name in server_tools: + with self.subTest(layer="server", name=name): + params = inspect.signature(getattr(server, name)).parameters + self.assertEqual(next(reversed(params)), "ctx") + self.assertIn("user_id", inspect.signature(server.get_usage).parameters) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/mcp_server_openviking_controlplane/tests/test_output.py b/server/mcp_server_openviking_controlplane/tests/test_output.py index ae9a9538..f49ef04e 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_output.py +++ b/server/mcp_server_openviking_controlplane/tests/test_output.py @@ -100,6 +100,45 @@ def test_collection_table_wraps_in_a_narrow_terminal(self): self.assertIn("demo", output) self.assertIn("READY", output) + def test_account_table_renders_count_timestamp_and_boolean(self): + data = { + "AccountList": [ + { + "AccountID": "default", + "UserCount": 3, + "CreateTime": "2026-08-27T12:34:56Z", + "IsDefault": True, + }, + { + "AccountID": "team-a", + "UserCount": 1, + "CreateTime": "1732100000", + "IsDefault": False, + }, + ], + "Total": 2, + } + + output = self.render(data, mode=OutputMode.PRETTY, view="accounts") + + self.assertIn("Data Spaces (2)", output) + self.assertIn("default", output) + self.assertIn("team-a", output) + self.assertIn("yes", output) + self.assertIn("no", output) + self.assertIn("2026-08-27T12:34:56Z", output) + self.assertIn("1732100000", output) + + def test_scoped_usage_does_not_render_library_billing_placeholder(self): + output = self.render( + {"CurContextFileNum": 12, "ResourcesFileNum": 4}, + mode=OutputMode.PRETTY, + view="usage", + ) + + self.assertIn("reported for the whole library, not per data space", output) + self.assertNotIn("¥—", output) + def test_api_key_view_warns_that_the_value_is_sensitive(self): output = self.render( {"UserID": "default", "Role": "admin", "ApiKey": "secret-key"}, 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 e0fee6d7..8b6f1784 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_server_transport.py +++ b/server/mcp_server_openviking_controlplane/tests/test_server_transport.py @@ -143,7 +143,25 @@ 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) + self.assertEqual( + {tool.name for tool in tools}, + { + "list_collections", + "get_collection", + "get_usage", + "get_collection_api_key", + "create_collection", + "update_collection", + "list_collection_users", + "register_collection_user", + "update_collection_user", + "delete_collection_user", + "list_collection_accounts", + "create_collection_account", + "delete_collection_account", + "delete_collection", + }, + ) for tool in tools: with self.subTest(tool=tool.name): self.assertNotIn("ctx", tool.input_schema.get("properties") or {}) diff --git a/server/mcp_server_openviking_controlplane/tests/test_user_contract.py b/server/mcp_server_openviking_controlplane/tests/test_user_contract.py index f9839404..40b087fd 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_user_contract.py +++ b/server/mcp_server_openviking_controlplane/tests/test_user_contract.py @@ -121,7 +121,9 @@ def test_api_key_accepts_user_id(self): ) self.assertEqual(result.exit_code, 0) - get_user_access.assert_called_once_with("ov-example", user_id="alice") + get_user_access.assert_called_once_with( + "ov-example", user_id="alice", account_id=None + ) def test_user_list_accepts_filters_and_pagination(self): with patch( @@ -155,6 +157,7 @@ def test_user_list_accepts_filters_and_pagination(self): role="user", page=2, limit=10, + account_id=None, ) def test_user_update_requires_regenerate_key(self): @@ -192,6 +195,7 @@ def test_api_key_tool_forwards_user_id(self): get_client.return_value.get_user_access.assert_called_once_with( "ov-example", user_id="alice", + account_id=None, ) def test_list_users_tool_forwards_filters_and_pagination(self): @@ -215,6 +219,7 @@ def test_list_users_tool_forwards_filters_and_pagination(self): role="user", page=2, limit=10, + account_id=None, ) def test_register_and_update_tools_match_backend_fields(self): @@ -232,11 +237,13 @@ def test_register_and_update_tools_match_backend_fields(self): get_client.return_value.register_user.assert_called_once_with( "ov-example", "alice", + account_id=None, ) get_client.return_value.update_user.assert_called_once_with( "ov-example", "alice", regenerate_key=True, + account_id=None, ) diff --git a/server/mcp_server_openviking_controlplane/uv.lock b/server/mcp_server_openviking_controlplane/uv.lock index 73f574f2..f9d31aa6 100644 --- a/server/mcp_server_openviking_controlplane/uv.lock +++ b/server/mcp_server_openviking_controlplane/uv.lock @@ -468,7 +468,7 @@ cli = [ [[package]] name = "mcp-server-openviking-controlplane" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "mcp", extra = ["cli"] }, From 8a8f6b17666126447ee0a4a590a0559aff9bae33 Mon Sep 17 00:00:00 2001 From: "zhengxiao.wu" Date: Thu, 10 Sep 2026 21:44:01 +0800 Subject: [PATCH 2/2] fix(openviking-controlplane): rename account scope to OpenVikingAccountID The control plane renamed the data-space field from AccountID to OpenVikingAccountID on both requests and responses, so account-scoped calls were rejected with "AccountID is empty". Send and read the new name across the shared client, the account renderer, the MCP tool contracts, and the wire tests. Replace personal names in the CLI and documentation examples with generic placeholders. --- .../README.md | 35 ++++++------- .../README_zh.md | 24 ++++----- .../skills/openviking-controlplane/SKILL.md | 49 +++++++++---------- .../mcp_server_openviking_controlplane/cli.py | 8 +-- .../client.py | 18 +++---- .../output.py | 2 +- .../server.py | 14 +++--- .../tests/test_account_contract.py | 24 ++++----- .../tests/test_output.py | 4 +- 9 files changed, 88 insertions(+), 90 deletions(-) diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index 1de91697..52ee49f5 100644 --- a/server/mcp_server_openviking_controlplane/README.md +++ b/server/mcp_server_openviking_controlplane/README.md @@ -31,13 +31,15 @@ have role `user`; `user update` currently supports API Key rotation only. The `account *` actions manage enterprise-tier data spaces: first-level isolation boundaries for users, credentials, memories, resources, sessions, and skills. The -released backend uses AccountID `default` when `--account-id` is omitted, preserving -existing behavior. Creating one automatically adds its `default` admin user. Account -IDs are 1-64 characters using only ASCII letters, digits, `_`, `.`, `@`, or `-`; they -cannot start with `_`, equal `.` or `..`, or contain more than one `@`. The -per-library quota is backend-configured (currently 100 by default). Deleting a data -space cascades to everything inside it and is irreversible; `default` cannot be -deleted. Treat `CreateTime` from `account list` as an opaque backend timestamp string. +released backend uses account `default` when `--account-id` is omitted, preserving +existing behavior. Every account-aware action serializes the scope as +`OpenVikingAccountID` on the wire. Creating one automatically adds its `default` +admin user. An OpenVikingAccountID is 1-64 characters using only ASCII letters, +digits, `_`, `.`, `@`, or `-`; it cannot start with `_`, equal `.` or `..`, or +contain more than one `@`. The per-library quota is backend-configured (currently +100 by default). Deleting a data space cascades to everything inside it and is +irreversible; `default` cannot be deleted. Treat `CreateTime` from `account list` +as an opaque backend timestamp string. ## Endpoint @@ -80,7 +82,7 @@ later without touching the rest. `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 +request — useful for swim-lane routing, e.g. `-H 'x-tt-env: '`. The `Authorization` and `Content-Type` headers are protected and cannot be overridden. ## CLI usage @@ -96,12 +98,12 @@ uv run ov-cp list uv run ov-cp get uv run ov-cp usage uv run ov-cp usage --account-id team-alpha -uv run ov-cp usage --user-id xiaohong # user in default data space -uv run ov-cp usage --account-id team-alpha --user-id xiaohong +uv run ov-cp usage --user-id alice # user in default data space +uv run ov-cp usage --account-id team-alpha --user-id alice uv run ov-cp api-key -uv run ov-cp api-key --user-id xiaohong +uv run ov-cp api-key --user-id alice uv run ov-cp api-key --account-id team-alpha -uv run ov-cp api-key --account-id team-alpha --user-id xiaohong +uv run ov-cp api-key --account-id team-alpha --user-id alice # create (consumes paid quota; always uses the AgentPlan model path and the # configured AgentPlan key; model source/parameters and image version are hidden) @@ -140,9 +142,9 @@ uv run ov-cp update --model-api-key ark-xxxxxxxx # manage users of an enterprise-tier library (key must be associated with it) uv run ov-cp user list uv run ov-cp user list --account-id team-alpha --role user --page 1 --limit 20 -uv run ov-cp user register xiaohong --account-id team-alpha -uv run ov-cp user update xiaohong --account-id team-alpha --regenerate-key -uv run ov-cp user delete xiaohong --account-id team-alpha --yes +uv run ov-cp user register alice --account-id team-alpha +uv run ov-cp user update alice --account-id team-alpha --regenerate-key +uv run ov-cp user delete alice --account-id team-alpha --yes # manage data spaces (accounts) in an enterprise-tier library uv run ov-cp account list --keyword team --page 1 --limit 20 @@ -170,8 +172,7 @@ Library-wide `usage` preserves the backend's legacy `EstimatedCosts` field and a 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. Account- or user-scoped usage omits -both library-wide estimates. `--user-id` may be used alone for the `default` account; -when `--account-id` is supplied, usage sends it as `OpenVikingAccountID` internally. +both library-wide estimates. `--user-id` may be used alone for the `default` account. 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` — diff --git a/server/mcp_server_openviking_controlplane/README_zh.md b/server/mcp_server_openviking_controlplane/README_zh.md index 4dddc794..38760fdf 100644 --- a/server/mcp_server_openviking_controlplane/README_zh.md +++ b/server/mcp_server_openviking_controlplane/README_zh.md @@ -29,8 +29,9 @@ OpenViking 控制面(topapi)的 MCP Server **与** CLI —— 用于管理 O 当前只支持重生 API Key。 `account *` 系列管理企业版库的数据空间:它是用户、凭证、记忆、资源、会话与技能的 -一级隔离边界。已发布后端在省略 `--account-id` 时使用 AccountID `default`,因此存量用法 -保持不变。新建数据空间时会自动添加其 `default` 管理员用户。AccountID 长度为 1-64 个 +一级隔离边界。已发布后端在省略 `--account-id` 时使用数据空间 `default`,因此存量用法 +保持不变。所有支持数据空间的接口都在请求体中以 `OpenVikingAccountID` 传递该范围。 +新建数据空间时会自动添加其 `default` 管理员用户。OpenVikingAccountID 长度为 1-64 个 字符,只能包含 ASCII 字母、数字、`_`、`.`、`@`、`-`;不能以 `_` 开头,不能等于 `.` 或 `..`,且至多包含一个 `@`。单库配额由后端配置(当前默认 100)。删除数据空间会不可逆 地级联删除其中所有内容;`default` 不可删除。将 `account list` 返回的 `CreateTime` 视为 @@ -73,7 +74,7 @@ Action 在 **path** 里(不走 `?Action=&Version=` query)。请求体是该 `VIKING_EXTRA_HEADERS` 是逗号分隔的 `Key: Value` 列表;`--header` 每次带一对、可重复 (CLI 优先于环境变量)。两者合并后加到每个请求上,常用于泳道路由,例如 -`-H 'x-tt-env: lujiakun'`。`Authorization`、`Content-Type` 为受保护头,不可覆盖。 +`-H 'x-tt-env: '`。`Authorization`、`Content-Type` 为受保护头,不可覆盖。 ## CLI 用法 @@ -88,12 +89,12 @@ uv run ov-cp list uv run ov-cp get uv run ov-cp usage uv run ov-cp usage --account-id team-alpha -uv run ov-cp usage --user-id xiaohong # default 数据空间内的用户 -uv run ov-cp usage --account-id team-alpha --user-id xiaohong +uv run ov-cp usage --user-id alice # default 数据空间内的用户 +uv run ov-cp usage --account-id team-alpha --user-id alice uv run ov-cp api-key -uv run ov-cp api-key --user-id xiaohong +uv run ov-cp api-key --user-id alice uv run ov-cp api-key --account-id team-alpha -uv run ov-cp api-key --account-id team-alpha --user-id xiaohong +uv run ov-cp api-key --account-id team-alpha --user-id alice # 建库(消耗付费配额;固定使用 AgentPlan 模型路径和已配置的 AgentPlan key, # 不开放模型来源、模型参数、模型鉴权与 OpenViking 镜像版本) @@ -127,9 +128,9 @@ uv run ov-cp update --model-api-key ark-xxxxxxxx # 管理企业版库的用户(key 需与该库已关联) uv run ov-cp user list uv run ov-cp user list --account-id team-alpha --role user --page 1 --limit 20 -uv run ov-cp user register xiaohong --account-id team-alpha -uv run ov-cp user update xiaohong --account-id team-alpha --regenerate-key -uv run ov-cp user delete xiaohong --account-id team-alpha --yes +uv run ov-cp user register alice --account-id team-alpha +uv run ov-cp user update alice --account-id team-alpha --regenerate-key +uv run ov-cp user delete alice --account-id team-alpha --yes # 管理企业版库的数据空间(Account) uv run ov-cp account list --keyword team --page 1 --limit 20 @@ -155,8 +156,7 @@ uv run ov-cp --output pretty list # 强制终端视图 库级 `usage` 保留后端原有的 `EstimatedCosts` 字段,同时新增 `EstimatedBilling`, 明确费用为每小时 CNY 估值。AgentPlan 支付的库还会返回对应的 AFP 抵扣量和 支付场景;`volc_pay` 只返回 CNY。按数据空间或用户查询时会去掉这两个库级估值。 -`--user-id` 可单独使用,此时查询 `default` 数据空间;传入 `--account-id` 时,usage -内部使用 `OpenVikingAccountID` 字段发送该范围。 +`--user-id` 可单独使用,此时查询 `default` 数据空间。 命令行参数优先于环境变量。端点默认指向公网网关;仅在测试时(如指向 port-forward)才用 `-e` / `VIKING_ENDPOINT` 覆盖:`uv run ov-cp -e http://localhost:18080 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 8bcba380..c5cdcaef 100644 --- a/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md +++ b/server/mcp_server_openviking_controlplane/skills/openviking-controlplane/SKILL.md @@ -39,11 +39,11 @@ 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 usage --account-id team-alpha -ov-cp usage --user-id xiaohong # user in account default -ov-cp usage --account-id team-alpha --user-id xiaohong +ov-cp usage --user-id alice # user in account default +ov-cp usage --account-id team-alpha --user-id alice 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 api-key --account-id team-alpha --user-id xiaohong +ov-cp api-key --user-id alice # selected user's plaintext key +ov-cp api-key --account-id team-alpha --user-id alice 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 @@ -52,15 +52,15 @@ ov-cp delete --yes # delete (irreversible; uninstalls the Helm rel # users of an enterprise-tier library (key must be associated with the library): ov-cp user list # users (ApiKey is masked) 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 +ov-cp user register alice # new users always get role=user +ov-cp user update alice --regenerate-key +ov-cp user delete alice --yes # revoke a user's credential # target a non-default data space with --account-id: ov-cp user list --account-id team-alpha -ov-cp user register xiaohong --account-id team-alpha -ov-cp user update xiaohong --account-id team-alpha --regenerate-key -ov-cp user delete xiaohong --account-id team-alpha --yes +ov-cp user register alice --account-id team-alpha +ov-cp user update alice --account-id team-alpha --regenerate-key +ov-cp user delete alice --account-id team-alpha --yes # data spaces (accounts) of an enterprise-tier library: ov-cp account list --keyword team --page 1 --limit 20 @@ -70,7 +70,7 @@ ov-cp account delete team-alpha # prompts before cascading deletio 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. Include `--account-id ` when +success and does not contain the new key. Include `--account-id ` when the user is outside account `default`. `update --model-api-key ` overwrites the library's AgentPlan MODEL @@ -97,18 +97,18 @@ Library-wide `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. Account- or user-scoped usage omits both library-wide fields. `--user-id` may be -used alone for a user in account `default`; account scope is sent internally as -`OpenVikingAccountID`. +used alone for a user in account `default`. ## Data spaces (accounts) Treat an account as an enterprise-tier library's first-level isolation boundary for users, credentials, memories, resources, sessions, and skills. Treat -`default` as the released backend's default AccountID, so omitting -`--account-id` preserves existing behavior. Expect a newly created data space to +`default` as the released backend's default account, so omitting +`--account-id` preserves existing behavior. Every account-aware action sends the +scope as `OpenVikingAccountID`. Expect a newly created data space to contain an automatically created `default` admin user. -Validate AccountID locally before sending a request; reject invalid input without +Validate OpenVikingAccountID locally before sending a request; reject invalid input without a backend request or cost. Apply all of these naming rules: - Require 1-64 characters. @@ -123,13 +123,13 @@ backend timestamp string. Never delete account `default`. Use this confirmation workflow before deleting any other data space: -1. First restate the target library ResourceID, exact AccountID, and full destruction scope: every user, credential, memory, resource, session, and skill in the data space. Ask the user to confirm that scope. -2. After that confirmation, require the user to repeat the exact AccountID. -3. Compare the repeated AccountID exactly with the target. Never infer an AccountID from a keyword or partial match in `account list`. -4. Only after both confirmations, run `ov-cp account delete --yes`. +1. First restate the target library ResourceID, exact OpenVikingAccountID, and full destruction scope: every user, credential, memory, resource, session, and skill in the data space. Ask the user to confirm that scope. +2. After that confirmation, require the user to repeat the exact OpenVikingAccountID. +3. Compare the repeated OpenVikingAccountID exactly with the target. Never infer an OpenVikingAccountID from a keyword or partial match in `account list`. +4. Only after both confirmations, run `ov-cp account delete --yes`. Never use `--yes` on the first deletion step. Stop if either confirmation is -missing or the repeated AccountID does not match exactly. +missing or the repeated OpenVikingAccountID does not match exactly. ## Creating a collection @@ -207,9 +207,8 @@ The returned `ApiKey` is the library's **data-plane** key. Use it as library. - `--account-id` applies to `api-key`, `usage`, and every `user *` command; omitting it selects account `default`. `usage --user-id ` is valid without it. -- Scoped usage omits both `EstimatedCosts` and `EstimatedBilling`; `get_usage` maps - account scope to `OpenVikingAccountID` internally. Treat account-list - `CreateTime` as opaque. +- Scoped usage omits both `EstimatedCosts` and `EstimatedBilling`. Treat + account-list `CreateTime` as opaque. - 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 + to a comma-separated `Key: Value` list — e.g. `-H 'x-tt-env: '` 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 255669b5..5f549bf3 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 @@ -91,7 +91,7 @@ def main_callback( 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 " + "VIKING_EXTRA_HEADERS (CLI wins). E.g. -H 'x-tt-env: ' to " "route into a swim-lane.", ), output: OutputMode = typer.Option( @@ -496,7 +496,7 @@ def account_list_cmd( keyword: Optional[str] = typer.Option( None, "--keyword", - help="Filter data spaces by AccountID substring.", + help="Filter data spaces by OpenVikingAccountID substring.", ), page: int = typer.Option(1, min=1, help="Page number (1-based)."), limit: int = typer.Option(20, min=1, max=200, help="Data spaces per page."), @@ -524,7 +524,7 @@ def account_create_cmd( resource_id: str = typer.Argument(..., help="Target library ResourceID."), account_id: str = typer.Argument( ..., - help=f"AccountID for the new data space. {ACCOUNT_ID_RULES}", + help=f"OpenVikingAccountID for the new data space. {ACCOUNT_ID_RULES}", ), ): """Create a data space; the backend also creates its default admin.""" @@ -539,7 +539,7 @@ def account_create_cmd( def account_delete_cmd( ctx: typer.Context, resource_id: str = typer.Argument(..., help="Target library ResourceID."), - account_id: str = typer.Argument(..., help="AccountID of the data space to delete."), + account_id: str = typer.Argument(..., help="OpenVikingAccountID of the data space to delete."), yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), ): """Delete a data space and everything isolated inside it (irreversible).""" 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 c49a4356..6479bb5d 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 @@ -154,7 +154,7 @@ def validate_account_id(account_id: str, label: str = "account_id") -> str: def _normalize_optional_account_id(account_id: Optional[str]) -> Optional[str]: - """Match the backend's optional AccountID normalization for user actions.""" + """Match the backend's optional account normalization for user actions.""" if account_id is None: return None if not isinstance(account_id, str): @@ -539,8 +539,6 @@ def get_usage( ) -> Dict[str, Any]: body: Dict[str, Any] = {"ResourceID": resource_id} if account_id is not None: - # Usage uses the backend's historical field name; other account-aware - # actions serialize this identifier as AccountID. body["OpenVikingAccountID"] = validate_account_id(account_id) if user_id is not None: # A UserID without an explicit account selects the default data space. @@ -585,7 +583,7 @@ def get_user_access( body["UserID"] = user_id normalized_account_id = _normalize_optional_account_id(account_id) if normalized_account_id is not None: - body["AccountID"] = normalized_account_id + body["OpenVikingAccountID"] = normalized_account_id return self._request("GetOpenVikingCollectionUserAccess", body) # --- User management (enterprise-tier libraries: multi-user) ------------- @@ -615,7 +613,7 @@ def list_collection_users( body["Role"] = role normalized_account_id = _normalize_optional_account_id(account_id) if normalized_account_id is not None: - body["AccountID"] = normalized_account_id + body["OpenVikingAccountID"] = normalized_account_id return self._request("ListOpenVikingCollectionUser", body) def register_user( @@ -630,7 +628,7 @@ def register_user( body: Dict[str, Any] = {"ResourceID": resource_id, "UserID": user_id} normalized_account_id = _normalize_optional_account_id(account_id) if normalized_account_id is not None: - body["AccountID"] = normalized_account_id + body["OpenVikingAccountID"] = normalized_account_id if extra: body.update(extra) return self._request("RegisterOpenVikingUser", body) @@ -655,7 +653,7 @@ def update_user( } normalized_account_id = _normalize_optional_account_id(account_id) if normalized_account_id is not None: - body["AccountID"] = normalized_account_id + body["OpenVikingAccountID"] = normalized_account_id if extra: body.update(extra) return self._request("UpdateOpenVikingUser", body) @@ -670,7 +668,7 @@ def delete_user( body: Dict[str, Any] = {"ResourceID": resource_id, "UserID": user_id} normalized_account_id = _normalize_optional_account_id(account_id) if normalized_account_id is not None: - body["AccountID"] = normalized_account_id + body["OpenVikingAccountID"] = normalized_account_id return self._request("DeleteOpenVikingUser", body) # --- Account management (enterprise-tier data spaces) ------------------- @@ -683,7 +681,7 @@ def create_account( ) -> Dict[str, Any]: body: Dict[str, Any] = { "ResourceID": resource_id, - "AccountID": validate_account_id(account_id), + "OpenVikingAccountID": validate_account_id(account_id), } if extra: body.update(extra) @@ -715,7 +713,7 @@ def delete_account(self, resource_id: str, account_id: str) -> Dict[str, Any]: raise ValueError("the default account cannot be deleted") return self._request( "DeleteOpenVikingAccount", - {"ResourceID": resource_id, "AccountID": account_id}, + {"ResourceID": resource_id, "OpenVikingAccountID": account_id}, ) 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 index be0b0688..d9489e93 100644 --- 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 @@ -139,7 +139,7 @@ def _accounts_table(rows: Any, total: Any) -> Any: if not isinstance(row, dict): continue table.add_row( - _text(row.get("AccountID")), + _text(row.get("OpenVikingAccountID")), _text(row.get("UserCount")), _text(row.get("CreateTime")), _value(row.get("IsDefault"), "IsDefault"), 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 18e20cbe..5d905784 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 @@ -470,12 +470,12 @@ def list_collection_accounts( Args: resource_id: target library ResourceID. - keyword: optional AccountID substring filter. + keyword: optional OpenVikingAccountID substring filter. page: 1-based page number; defaults to 1. limit: data spaces per page, 1 to 200; defaults to 20. Returns: - {"AccountList": [{"AccountID", "UserCount", "CreateTime", + {"AccountList": [{"OpenVikingAccountID", "UserCount", "CreateTime", "IsDefault"}], "Total": N} """ try: @@ -495,7 +495,7 @@ def list_collection_accounts( "Create a data space (account), an isolation boundary within a library. " "CONFIRM WITH THE USER before calling. The backend creates a default " "admin in the new data space; the per-library account limit is " - f"backend-configured. AccountID {ACCOUNT_ID_RULES}." + f"backend-configured. OpenVikingAccountID {ACCOUNT_ID_RULES}." ) ) def create_collection_account( @@ -510,16 +510,16 @@ def create_collection_account( and skills. The backend automatically creates a ``default`` admin inside the new data space. The per-library account limit is backend-configured. - AccountID is validated locally: it must be 1-64 characters using only + OpenVikingAccountID is validated locally: it must be 1-64 characters using only letters, digits, '_', '.', '@', or '-'; must not start with '_'; must not be '.' or '..'; and may contain at most one '@'. Args: resource_id: target library ResourceID. - account_id: AccountID for the new data space. + account_id: OpenVikingAccountID for the new data space. Returns: - {"Success": true, "AccountID": "..."} + {"Success": true, "OpenVikingAccountID": "..."} """ try: return get_client(ctx).create_account(resource_id, account_id) @@ -543,7 +543,7 @@ def delete_collection_account( Args: resource_id: target library ResourceID. - account_id: AccountID of the data space to delete. + account_id: OpenVikingAccountID of the data space to delete. Returns: {"Success": true} diff --git a/server/mcp_server_openviking_controlplane/tests/test_account_contract.py b/server/mcp_server_openviking_controlplane/tests/test_account_contract.py index 05a10ca1..2b710295 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_account_contract.py +++ b/server/mcp_server_openviking_controlplane/tests/test_account_contract.py @@ -22,7 +22,7 @@ def test_create_account_sends_exact_wire_body(self): with patch.object( self.client, "_request", - return_value={"Success": True, "AccountID": "team.alpha"}, + return_value={"Success": True, "OpenVikingAccountID": "team.alpha"}, ) as request: result = self.client.create_account( "ov-example", @@ -30,12 +30,12 @@ def test_create_account_sends_exact_wire_body(self): extra={"TraceTag": "test"}, ) - self.assertEqual(result["AccountID"], "team.alpha") + self.assertEqual(result["OpenVikingAccountID"], "team.alpha") request.assert_called_once_with( "CreateOpenVikingAccount", { "ResourceID": "ov-example", - "AccountID": "team.alpha", + "OpenVikingAccountID": "team.alpha", "TraceTag": "test", }, ) @@ -103,7 +103,7 @@ def test_delete_account_sends_exact_wire_body(self): request.assert_called_once_with( "DeleteOpenVikingAccount", - {"ResourceID": "ov-example", "AccountID": "team-alpha"}, + {"ResourceID": "ov-example", "OpenVikingAccountID": "team-alpha"}, ) def test_account_id_validation_rejects_invalid_values(self): @@ -168,7 +168,7 @@ def test_existing_user_actions_forward_validated_account_id(self): ( lambda: self.client.get_user_access("ov-example", account_id="team"), "GetOpenVikingCollectionUserAccess", - {"ResourceID": "ov-example", "AccountID": "team"}, + {"ResourceID": "ov-example", "OpenVikingAccountID": "team"}, ), ( lambda: self.client.list_collection_users( @@ -179,7 +179,7 @@ def test_existing_user_actions_forward_validated_account_id(self): "ResourceID": "ov-example", "Page": 1, "Limit": 20, - "AccountID": "team", + "OpenVikingAccountID": "team", }, ), ( @@ -187,7 +187,7 @@ def test_existing_user_actions_forward_validated_account_id(self): "ov-example", "alice", account_id="team" ), "RegisterOpenVikingUser", - {"ResourceID": "ov-example", "UserID": "alice", "AccountID": "team"}, + {"ResourceID": "ov-example", "UserID": "alice", "OpenVikingAccountID": "team"}, ), ( lambda: self.client.update_user( @@ -198,7 +198,7 @@ def test_existing_user_actions_forward_validated_account_id(self): "ResourceID": "ov-example", "UserID": "alice", "RegenerateKey": True, - "AccountID": "team", + "OpenVikingAccountID": "team", }, ), ( @@ -206,7 +206,7 @@ def test_existing_user_actions_forward_validated_account_id(self): "ov-example", "alice", account_id="team" ), "DeleteOpenVikingUser", - {"ResourceID": "ov-example", "UserID": "alice", "AccountID": "team"}, + {"ResourceID": "ov-example", "UserID": "alice", "OpenVikingAccountID": "team"}, ), ) for call, action, body in calls: @@ -235,11 +235,11 @@ def test_existing_user_actions_normalize_optional_account_id(self): with self.subTest(call=call, value="blank"): with patch.object(self.client, "_request", return_value={}) as request: call(" ") - self.assertNotIn("AccountID", request.call_args.args[1]) + self.assertNotIn("OpenVikingAccountID", request.call_args.args[1]) with self.subTest(call=call, value="trimmed"): with patch.object(self.client, "_request", return_value={}) as request: call(" team ") - self.assertEqual(request.call_args.args[1]["AccountID"], "team") + self.assertEqual(request.call_args.args[1]["OpenVikingAccountID"], "team") def test_existing_actions_reject_invalid_account_id_before_request(self): calls = ( @@ -359,7 +359,7 @@ def test_account_list_forwards_filters_and_pagination(self): def test_account_create_forwards_identifiers(self): with patch( "mcp_server_openviking_controlplane.cli.ControlPlaneClient.create_account", - return_value={"Success": True, "AccountID": "team"}, + return_value={"Success": True, "OpenVikingAccountID": "team"}, ) as create_account: result = self.runner.invoke( app, diff --git a/server/mcp_server_openviking_controlplane/tests/test_output.py b/server/mcp_server_openviking_controlplane/tests/test_output.py index f49ef04e..30aff9ce 100644 --- a/server/mcp_server_openviking_controlplane/tests/test_output.py +++ b/server/mcp_server_openviking_controlplane/tests/test_output.py @@ -104,13 +104,13 @@ def test_account_table_renders_count_timestamp_and_boolean(self): data = { "AccountList": [ { - "AccountID": "default", + "OpenVikingAccountID": "default", "UserCount": 3, "CreateTime": "2026-08-27T12:34:56Z", "IsDefault": True, }, { - "AccountID": "team-a", + "OpenVikingAccountID": "team-a", "UserCount": 1, "CreateTime": "1732100000", "IsDefault": False,