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