diff --git a/server/mcp_server_openviking_controlplane/README.md b/server/mcp_server_openviking_controlplane/README.md index b3b44a9f..52ee49f5 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,18 @@ 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 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 The control-plane TopAPI is compiled into the OpenViking **data-plane cluster**; @@ -53,8 +68,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 @@ -67,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 @@ -82,8 +97,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 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 alice # 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 +141,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 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 +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 +168,11 @@ 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. 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 +194,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 +293,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..38760fdf 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,29 @@ 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` 时使用数据空间 `default`,因此存量用法 +保持不变。所有支持数据空间的接口都在请求体中以 `OpenVikingAccountID` 传递该范围。 +新建数据空间时会自动添加其 `default` 管理员用户。OpenVikingAccountID 长度为 1-64 个 +字符,只能包含 ASCII 字母、数字、`_`、`.`、`@`、`-`;不能以 `_` 开头,不能等于 `.` +或 `..`,且至多包含一个 `@`。单库配额由后端配置(当前默认 100)。删除数据空间会不可逆 +地级联删除其中所有内容;`default` 不可删除。将 `account list` 返回的 `CreateTime` 视为 +后端不透明时间戳字符串。 + ## 端点 控制面 TopAPI 接口已编译进 OpenViking **数据面集群**,每个 Action 由数据面网关在如下路径提供: @@ -49,7 +61,7 @@ Action 在 **path** 里(不走 `?Action=&Version=` query)。请求体是该 (`common/auth.py` → `BearerTokenAuth`),后续要换 AK/SK 签名时只需替换这一处。 > ⚠️ `create` 等写接口要求账号已**开通 AgentPlan 抵扣**,否则返回 `ProductUnordered`; -> 只读接口(list/get/usage/delete)不受此限。 +> 操作已有库时还可能要求 AgentPlan key 已与目标库关联。 ### 配置 @@ -62,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 用法 @@ -76,8 +88,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 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 alice # 建库(消耗付费配额;固定使用 AgentPlan 模型路径和已配置的 AgentPlan key, # 不开放模型来源、模型参数、模型鉴权与 OpenViking 镜像版本) @@ -110,17 +127,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 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 +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 +153,10 @@ 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` 数据空间。 命令行参数优先于环境变量。端点默认指向公网网关;仅在测试时(如指向 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..c5cdcaef 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 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 --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 @@ -48,14 +52,26 @@ 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 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 +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`. + +## 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 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 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. +- 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 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 OpenVikingAccountID does not match exactly. ## Creating a collection @@ -145,14 +195,20 @@ 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`. 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 a481afbc..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 @@ -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, ) @@ -84,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( @@ -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 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."), +): + """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"OpenVikingAccountID 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="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).""" + 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..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 @@ -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 account 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,28 @@ 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: + 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 +570,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 +581,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["OpenVikingAccountID"] = normalized_account_id return self._request("GetOpenVikingCollectionUserAccess", body) # --- User management (enterprise-tier libraries: multi-user) ------------- @@ -530,12 +598,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 +611,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["OpenVikingAccountID"] = 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["OpenVikingAccountID"] = normalized_account_id if extra: body.update(extra) return self._request("RegisterOpenVikingUser", body) @@ -565,6 +638,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 +651,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["OpenVikingAccountID"] = 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["OpenVikingAccountID"] = 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, + "OpenVikingAccountID": 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, "OpenVikingAccountID": 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..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 @@ -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("OpenVikingAccountID")), + _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..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 @@ -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 OpenVikingAccountID substring filter. + page: 1-based page number; defaults to 1. + limit: data spaces per page, 1 to 200; defaults to 20. + + Returns: + {"AccountList": [{"OpenVikingAccountID", "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. OpenVikingAccountID {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. + + 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: OpenVikingAccountID for the new data space. + + Returns: + {"Success": true, "OpenVikingAccountID": "..."} + """ + 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: OpenVikingAccountID 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..2b710295 --- /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, "OpenVikingAccountID": "team.alpha"}, + ) as request: + result = self.client.create_account( + "ov-example", + "team.alpha", + extra={"TraceTag": "test"}, + ) + + self.assertEqual(result["OpenVikingAccountID"], "team.alpha") + request.assert_called_once_with( + "CreateOpenVikingAccount", + { + "ResourceID": "ov-example", + "OpenVikingAccountID": "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", "OpenVikingAccountID": "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", "OpenVikingAccountID": "team"}, + ), + ( + lambda: self.client.list_collection_users( + "ov-example", account_id="team" + ), + "ListOpenVikingCollectionUser", + { + "ResourceID": "ov-example", + "Page": 1, + "Limit": 20, + "OpenVikingAccountID": "team", + }, + ), + ( + lambda: self.client.register_user( + "ov-example", "alice", account_id="team" + ), + "RegisterOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice", "OpenVikingAccountID": "team"}, + ), + ( + lambda: self.client.update_user( + "ov-example", "alice", True, account_id="team" + ), + "UpdateOpenVikingUser", + { + "ResourceID": "ov-example", + "UserID": "alice", + "RegenerateKey": True, + "OpenVikingAccountID": "team", + }, + ), + ( + lambda: self.client.delete_user( + "ov-example", "alice", account_id="team" + ), + "DeleteOpenVikingUser", + {"ResourceID": "ov-example", "UserID": "alice", "OpenVikingAccountID": "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("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]["OpenVikingAccountID"], "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, "OpenVikingAccountID": "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..30aff9ce 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": [ + { + "OpenVikingAccountID": "default", + "UserCount": 3, + "CreateTime": "2026-08-27T12:34:56Z", + "IsDefault": True, + }, + { + "OpenVikingAccountID": "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"] },