From ecb0dc08b82b18ba12cd564ef040a94c8d58740b Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 05:46:12 +0000 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=96=E9=83=A8MCP?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E5=B7=A5=E5=85=B7=E5=B9=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8E=9F=E6=9C=89=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增十五个语义工具及中文说明和分支参数契约 复用现有API分派并支持新入口的可选创建幂等键 保留全部原工具和API并补充分派与兼容回归测试 同步工程文档及验收记录并注明本地数据库阻断的运行验证 --- docs/README.md | 2 +- ...实施计划】外部MCP语义工具并存-2026-09-23.md | 35 + ...€�里程碑】外部MCP语义工具并存-2026-09-23.md | 51 ++ .../shared-memory/document-map.md | 2 +- ...¡ˆ】外部MCP语义工具说明与参数设计-2026-09-23.md | 60 +- ...¶构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 +- .../prompts/external_mcp/semantic_tools.json | 62 ++ .../crates/api-server/src/external_mcp.rs | 384 +++++++++- .../api-server/src/external_mcp/semantic.rs | 431 ++++++++++++ .../src/external_mcp/semantic/tests.rs | 666 ++++++++++++++++++ 10 files changed, 1640 insertions(+), 55 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】外部MCP语义工具并存-2026-09-23.md create mode 100644 docs/project-memory/plans/【里程碑】外部MCP语义工具并存-2026-09-23.md create mode 100644 server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json create mode 100644 server-rs/crates/api-server/src/external_mcp/semantic.rs create mode 100644 server-rs/crates/api-server/src/external_mcp/semantic/tests.rs diff --git a/docs/README.md b/docs/README.md index 779df7a45..582761faa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ - [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。 - [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):只描述现役平台壳与图片画布编辑器链路。 - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) -- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):仅复用现有 External API 的 15 个候选工具、操作映射、说明和参数边界;正式工程设计参考,语义工具尚未实现。 +- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 ## AI 游戏创作与 Agent Runtime diff --git a/docs/project-memory/plans/【实施计划】外部MCP语义工具并存-2026-09-23.md b/docs/project-memory/plans/【实施计划】外部MCP语义工具并存-2026-09-23.md new file mode 100644 index 000000000..b0665ac29 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】外部MCP语义工具并存-2026-09-23.md @@ -0,0 +1,35 @@ +# 外部 MCP 语义工具并存实施计划 + +| 字段 | 值 | +| --- | --- | +| Milestone | [外部 MCP 语义工具并存](./【里程碑】外部MCP语义工具并存-2026-09-23.md) | +| Status | awaiting-runtime-verification | +| Owner | Agent | + +## 修改边界 + +MCP 模块、所属工具提示词、定向测试、主规范及文档索引。REST DTO/路由/OpenAPI、resources/instructions、数据库和 CLI 保持原样。 + +## 实现顺序 + +1. 评审主规范和里程碑,收口剩余技术歧义。 +2. 在 MCP 内增加语义注册与 schema 构造;提示词置于所属 prompts 目录。 +3. 新工具转换为现有 operation 参数后复用分派;可选幂等只影响新入口。 +4. 验证各 action 映射、旧目录兼容、错误与权限边界;同步文档状态。 + +## 验证命令 + +- `cargo test --locked -p api-server external_mcp` +- `cargo test --locked -p api-server external_api_auth` +- `cargo test --locked -p api-server external_`(覆盖认证及相关 External API 回归) +- 对修改的 Rust 文件运行 `rustfmt --check`。 +- `npm run dev:api-server` 与实际端口 `/healthz`;只启动/停止本任务进程。 +- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`。 + +## 风险与回滚点 + +schema 条件保真、幂等键可选分支和新增目录体积是重点。只增加 MCP 适配,不复制业务处理;异常时可回滚新增语义注册而不迁移数据。不得将内存 router 测试作为真实 Provider/账号验收。 + +## 当前状态 + +实现与自动化回归完成;155 项 External 相关测试通过。运行时启动因本地 SpacetimeDB 连接拒绝而未完成健康检查,详情见里程碑证据表。恢复可用的本地数据库配置后只需补跑 smoke 和验收,不重复扩展业务实现。 diff --git a/docs/project-memory/plans/【里程碑】外部MCP语义工具并存-2026-09-23.md b/docs/project-memory/plans/【里程碑】外部MCP语义工具并存-2026-09-23.md new file mode 100644 index 000000000..fbd31452d --- /dev/null +++ b/docs/project-memory/plans/【里程碑】外部MCP语义工具并存-2026-09-23.md @@ -0,0 +1,51 @@ +# 外部 MCP 语义工具并存 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | awaiting-runtime-verification | +| Date | 2026-09-23 | +| Parent Spec | [外部 MCP 语义工具说明与参数设计](../../technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md) | + +## 目标与范围 + +同一托管 MCP 增加主规范的 15 个语义工具及完整输入说明;复用现有 External API,实现 action 分派、参数位置转换及同源业务结果。 + +## 非目标 + +不删除、隐藏、重命名或改变任何旧工具/API;不新增 REST 能力,不修改 SpacetimeDB,不改 resources、instructions、CLI、独立 Skill 或发布页,不部署。 + +## 依赖与前置条件 + +主规范第 5 节固定工具名、schema、结果、可选幂等与新旧并存方式;开发前完成独立评审。 + +独立评审结论:范围、兼容、失败语义与验收无阻塞项;分支 schema 与运行时均须拒绝错分支字段,可选幂等不得改变旧入口行为。 + +## 验收标准 + +- [x] 15 个新工具和原目录同时可见,原工具定义保持一致。 +- [x] 每个 action 只调用一个已有 operation;路径、查询、body 与幂等头正确。 +- [x] 错 action、缺字段、跨分支字段和 ownerUserId 注入在分派前拒绝。 +- [x] 必填及可选幂等键转发正确,重复语义入口不改变规范请求。 +- [x] 成功结果、告警和结构化业务错误沿用既有处理,无额外投影。 +- [x] API Key/owner/scope 边界继续在现有 router 生效。 +- [x] 定向 Rust 测试、格式、编码和文档检查通过。 +- [ ] 本地 healthz smoke 通过。 + +## 证据要求 + +自动化覆盖目录、分派、参数、幂等、传输和认证;运行时执行本地 API smoke。真实账号/Provider/客户端未验证项单独记录,不自动触发付费生成。 + +## 验收证据 + +| 条款 | 验证 | 结果与边界 | +| --- | --- | --- | +| 工具目录与字段合同 | `cargo test --locked -p api-server external_mcp` | 24 项通过;44 个工具可见,31 个 action 路由覆盖,原工具序列化定义保留,全部 schema 无外部引用且满足既有体积上限 | +| 错分支、请求映射、幂等 | 同上 | 错分支/owner 注入拒绝,路径编码和查询/body 分离,可选/必填 key,两个 quick-edit 入口的规范请求一致 | +| 权限、异步与旧 REST | `cargo test --locked -p api-server external_` | 155 项通过;含上述 24 项、MCP 内外两次认证和 scope 403、既有跨 owner 不可见、幂等、参数校验、OpenAPI 与 Skill 回归 | +| 编译与文本 | `cargo check --locked -p api-server`、定向 rustfmt、文档索引、编码和 diff 检查 | 通过 | +| 运行时 | `npm run dev:api-server` 与本地 healthz | 可执行文件编译成功、BgFilter worker 启动;API 启动被本地 SpacetimeDB 连接拒绝阻断,healthz 与未认证 MCP initialize 超时。本次使用隔离测试数据库名,未发布数据库或调用付费生成,启动进程已清理 | + +未验证:真实账号下的端到端数据库读写、付费 Provider 生成、外部 MCP 客户端对 oneOf 参数的展示与使用、远端部署。内存 router 和同源请求映射证明适配链路,不替代这些真实运行环境验收。 + +剩余工作仅为在可用的本地数据库配置下重跑启动 smoke,并取得本里程碑验收结论;不自动扩大为开发环境修复或 resources/instructions 改造。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index fae1f5681..6df79f741 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -24,7 +24,7 @@ 外部 MCP 语义工具设计: 1. [外部 OpenAPI 与 API Key 接入方案](../../【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md):现役托管 MCP 与 External API 合同。 -2. [外部 MCP 语义工具说明与参数设计](../../technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):正式工程参考,记录仅复用现有 API 的 15 个候选工具与参数设计;工具尚未实现,不作为已上线能力说明。 +2. [外部 MCP 语义工具说明与参数设计](../../technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个语义工具与全部旧工具并存,复用现有 API 分派和 schema;多功能入口使用 action/input,结果不裁剪,可选幂等只扩展新入口。resources/instructions 仍沿用原内容,线上状态按实际部署核对。 AI 游戏创作 / DirectProject / UI workflow: diff --git a/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md b/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md index 699c0e97c..19452b90f 100644 --- a/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md +++ b/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md @@ -2,11 +2,11 @@ 更新时间:`2026-09-23` -> 文档状态:`current`(正式工程设计参考;语义工具尚未实现,不能据此认定当前服务已提供这些入口)。 +> 文档状态:`current`(工程实现合同;仓库已增加语义工具,线上可用性以实际部署版本为准)。 > > 父规范:[外部 OpenAPI 与 API Key 接入方案](../【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)。 > -> 当前字段契约:[External v1 OpenAPI](../openapi/genarrative-external-v1.openapi.json)。本文记录工具划分、说明和参数设计方向;最终英文工具名、完整 JSON Schema、结果投影及兼容发布方式仍需在实现前明确。 +> 当前字段契约:[External v1 OpenAPI](../openapi/genarrative-external-v1.openapi.json)。本文记录工具划分、说明和参数合同;英文工具名、schema 构造、结果与兼容方式见第 5 节。 ## 1. 目标与范围 @@ -14,13 +14,13 @@ 工具可以存在自然的功能交集:例如参考图快速变体同时属于生成图片和修改图片,读取画布同时服务于项目查找和布局编辑。不以工具数量或 API 唯一归属为目标,不把不相关任务硬合成一个工具。 -本文只讨论工具说明、操作选择、关键参数和业务边界。开发者发布页、文档存储与独立发布、CLI 改造不属于本轮范围。本文不改变现有 REST 路由、DTO 和运行行为,也不直接替换现役 MCP 契约。进入工程实现时,按仓库规范驱动流程补齐对应里程碑规范、实现计划和验收条件。 +本文约定工具说明、操作选择、参数和业务边界。开发者发布页、文档存储与独立发布、CLI 改造不属于本轮范围。现有 REST 路由、DTO 和运行行为保持不变,新语义工具与全部原有工具并存。 ## 2. 当前实现与目标调用链 当前 [external_mcp.rs](../../server-rs/crates/api-server/src/external_mcp.rs) 从 OpenAPI 自动建立原子工具:工具名由 `operationId` 转为 snake_case;description 优先读取 operation 的 `description`,缺省读取 `summary`,附加 HTTP 方法和路径;参数 schema 来自 OpenAPI。服务级 instructions 提供通用工作流,resources 提供详细说明。 -新工具拟继续部署在同一个 API server 内,复用现有分派和 External REST router: +新工具继续部署在同一个 API server 内,复用现有分派和 External REST router: ```text Agent 调用语义工具 @@ -69,7 +69,7 @@ Agent 调用语义工具 ### 3.3 共用行为 - 九类生成 POST 继续要求稳定幂等键,并返回 `202 + operationId`;工具不能把受理当成生成完成。 -- 项目创建、项目资源登记、文件夹创建的 REST API 支持可选幂等键,新工具设计应保留该能力。当前 MCP bridge 主要按 required header 推导幂等参数,实现时须明确接入这些可选 header;素材记录创建 API 不在该可选幂等合同中。 +- 项目创建、项目资源登记、文件夹创建的 REST API 支持可选幂等键,新语义入口提供并转发这些可选 header;原工具继续保持仅按 required header 推导幂等参数的行为。素材记录创建 API 不在该可选幂等合同中。 - 相同 API 能力出现在不同语义入口时,参数约束、底层请求和结果语义保持一致。重试或切换入口恢复同一逻辑请求时,保留原 API 操作、规范请求和幂等键,不因换工具名创建新任务。 - `projectId`、`assetFolderId`、`assetLabel`、`canvasCompletion` 等目标参数只在对应 API 支持时提供。UI 素材提取使用 `spritesheetLabel`;各生成族不共享未经核对的字段全集。 - owner 由 API Key 确定,不让 Agent 指定身份,不把 API Key 放进工具参数。 @@ -78,7 +78,7 @@ Agent 调用语义工具 ## 4. 工具说明与操作映射 -以下共 15 个候选工具。删除从项目管理、素材库整理中移出,统一放入 XV。action 名称是本设计的候选值,不表示已经上线。 +以下共 15 个新增工具。删除从项目管理、素材库整理中移出,统一放入 XV。本节说明仓库实现合同,不表示线上已部署。 ### I. 查找画布项目 @@ -111,11 +111,11 @@ Agent 调用语义工具 | --- | --- | --- | | `list_library` | 无 | `getEditorAssetLibrary` | | `get_project_resources` | 必填 `projectId` | `getEditorProject` | -| `get_download_url` | `objectKey`;可选 `expireSeconds` | `getExternalAssetReadUrl` | +| `get_download_url` | `objectKey` 或兼容的 `legacyPublicPath`;可选 `expireSeconds` | `getExternalAssetReadUrl` | -项目资源查询复用项目详情,不新增资源查询 API。建议仅投影其资源部分,具体输出结构在实现前固定,并保留资源身份字段。 +项目资源查询复用项目详情,不新增资源查询 API。本次返回完整项目详情,资源位于其中的 resources;保持与项目读取相同的结构,不新增裁剪投影。 -读取素材记录、获取文件地址与查看媒体内容是不同操作。工具不提供本地下载、关键词检索或相似素材搜索。临时签名 URL 用于访问媒体,不作为持久化生成引用。REST 还支持 `legacyPublicPath`;语义入口优先使用稳定 objectKey,是否保留该兼容查询字段在完整 schema 评审时明确,不改 REST 契约。 +读取素材记录、获取文件地址与查看媒体内容是不同操作。工具不提供本地下载、关键词检索或相似素材搜索。临时签名 URL 用于访问媒体,不作为持久化生成引用。语义入口保留 REST 的 `legacyPublicPath` 可选查询字段;优先使用稳定 objectKey,至少提供一种来源,由现有 API 校验来源,不改 REST 契约。 ### IV. 办理素材上传 @@ -289,20 +289,50 @@ register_resource 只登记资源,并不自动创建画布图层。正常生 不增加批量、按名称或模糊匹配删除,不用一个可伪造的 confirm 参数替代真实用户授权和后端鉴权。 -## 5. 实现前仍需明确的事项 +## 5. 实现合同与验收 -1. 最终英文工具名与完整 action schema;条件参数如何在目标 MCP 客户端中准确展示和校验。 -2. III 的项目资源投影结构及 legacyPublicPath 保留策略;各工具结果保持原样或裁剪时的精确字段。 -3. 新旧工具替换、并存或迁移的具体选择。本文不预设双套目录、默认开关或 api_* 逃生入口。 -4. 更新服务级 instructions、工具说明与相关使用资料,确保与实际广告的工具一致;文档存储和发布机制另行讨论。 +### 5.1 工具名与兼容 -实现验收围绕实际风险:操作路由和参数位置正确、缺失或错分支参数不触发副作用、原有 owner/scope 校验有效、同一逻辑生成幂等恢复、图集降级告警保留、画布 revision 冲突保留,以及重复语义入口调用同一底层能力的结果一致性。本文完成不代表这些运行时验证已经通过。 +| 编号 | 工具名 | +| --- | --- | +| I | `find_canvas_projects` | +| II | `manage_canvas_projects` | +| III | `find_assets` | +| IV | `prepare_asset_upload` | +| V | `generate_image` | +| VI | `modify_image` | +| VII | `generate_icon_spritesheet` | +| VIII | `extract_ui_assets` | +| IX | `generate_character_animation` | +| X | `generate_video` | +| XI | `generate_audio` | +| XII | `edit_canvas` | +| XIII | `organize_asset_library` | +| XIV | `check_generation` | +| XV | `delete_resources` | + +现有 MCP 工具和 REST API 全部保留;旧工具名称、schema、注解、调用行为和可见性不变。新工具直接追加进同一个 tools/list,不加开关、不改旧工具前缀、不设隐藏目录。更新工具说明和参数属于本轮;resources、instructions、独立 Skill、CLI、发布页与部署不属于本轮。 + +### 5.2 参数与结果 + +- 字段 schema 从对应现有 OpenAPI operation 构造,展开本地引用并保留嵌套类型、枚举、条件和字段说明。路径、查询与 body 字段平铺到单功能工具顶层或多功能工具的 input;不另写一份业务字段全集。 +- 多功能工具以顶层 object 加 oneOf 表达互斥 action,每个分支含 action 常量和完整 input schema。input 必填,无业务参数时传 `{}`。idempotencyKey 仅放在工具顶层:九类生成必填,项目创建、资源登记、文件夹创建可选,其它 action 不接受。 +- MCP 适配层在分派前校验 action、信封字段、所选分支允许的字段、必填参数及直接字段的类型/枚举/常量;嵌套字段值和业务组合继续由现有 REST DTO/校验处理,不创建第二套业务验证器。协议 schema 与分派校验共同限制错分支参数;未知顶层或 input 字段不能被静默丢弃。 +- variation 不暴露 kind,固定注入 `quick-edit`;对象确认不暴露或接受 ownerUserId。其余公开字段全部保留。稳定 key、原 API 与规范请求保持一致,重复语义入口不新增幂等命名空间。 +- 项目列表固定 summary。get_project_resources 返回完整项目响应,不裁剪;下载地址保留 objectKey、legacyPublicPath 与 expireSeconds。所有结果复用现有成功解包与结构化错误处理,不裁剪告警、revision 冲突或异步结果。 +- 读取工具标记 readOnly;含删除、覆盖或移动已有状态的工具标记 destructive;生成类说明付费及异步语义,可能替换已有图层的生成工具也按潜在破坏性标记。openWorld 仅用于会访问外部服务的能力,不作为付费标志。注解不替代用户授权或后端校验。 + +### 5.3 验收 + +验证工具目录追加与旧定义一致、全部 action 路由与字段位置、必填和错分支拒绝、可选/必填幂等头、quick-edit 与普通生成入口等价、原 owner/scope 边界、结构化结果/告警和 revision 冲突透传。运行 api-server 定向测试与本地 healthz smoke;真实账号、付费 Provider 和具体 MCP 客户端尚未验证时明确记录,不能将单元测试当作线上验收。 ## 6. 核对入口 - [External v1 OpenAPI](../openapi/genarrative-external-v1.openapi.json):operation、参数、请求体、响应与公开字段权威。 - [External API 路由](../../server-rs/crates/api-server/src/modules/external_api.rs):实际路由挂载。 - [MCP 实现](../../server-rs/crates/api-server/src/external_mcp.rs):工具生成、schema、资源和进程内分派。 +- [语义工具适配](../../server-rs/crates/api-server/src/external_mcp/semantic.rs):新增工具、action、同源 schema 与参数映射。 +- [工具说明](../../server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json):新增工具的中文用途、操作和结果说明。 - [External 编辑器接口](../../server-rs/crates/api-server/src/external_editor_api.rs):鉴权、请求处理与生成受理。 - [AGC 抠图模式与背景色透传](./【技术方案】AGC抠图模式与背景色透传-2026-09-16.md):去背景模式与背景色已有合同。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index c87066850..4712f506b 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -78,7 +78,7 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原 ## 托管远程 MCP -后续语义工具的设计参考见 [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md)。该文档仅规划复用现有 External API 的新工具说明、操作划分和参数;尚未实现,不替代下述现役 MCP 契约。 +新增语义工具的合同见 [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md)。15 个语义工具与原有 OpenAPI 自动映射工具同时可见;旧工具名称、schema、注解和行为保持不变,不删除、隐藏或加前缀。新工具按 action 选择一个既有 operation,平铺业务字段后复用下述 REST 分派;创建项目、项目资源、素材文件夹的新入口额外保留对应 API 的可选幂等键。所有结果沿用相同解包与错误处理,不裁剪告警或项目快照。resources 与 instructions 的内容更新另行处理,REST 契约不变。 `/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize`、`notifications/initialized`、`ping`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。 diff --git a/server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json b/server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json new file mode 100644 index 000000000..61b71f41a --- /dev/null +++ b/server-rs/crates/api-server/prompts/external_mcp/semantic_tools.json @@ -0,0 +1,62 @@ +{ + "find_canvas_projects": { + "title": "查找画布项目", + "description": "查找已有画布项目。action=list 返回项目摘要,recent 读取最近项目,get 按 projectId 读取完整项目、画布、图层和资源。先定位再读取;名称有歧义时明确候选,不静默创建替代项目。当前不提供分页、limit 或名称搜索参数。" + }, + "manage_canvas_projects": { + "title": "管理画布项目", + "description": "创建或重命名画布项目。action=create 可选 title 和顶层 idempotencyKey,只创建项目,不自动创建同名素材文件夹;rename 必须提供 projectId 和 title。返回现有 API 的业务结果。删除使用 delete_resources。" + }, + "find_assets": { + "title": "查找与读取素材", + "description": "查看素材记录或获取临时下载地址。action=list_library 读取账号素材库;get_project_resources 按 projectId 返回完整项目详情(含 resources),不裁剪;get_download_url 通过 objectKey 或兼容的 legacyPublicPath 获取临时访问 URL,可选 expireSeconds。优先稳定 objectKey;临时 URL 不作为持久生成引用。读取记录不等于查看媒体,不提供本地下载、关键词或相似素材搜索。" + }, + "prepare_asset_upload": { + "title": "办理素材上传", + "description": "办理素材上传的两个独立步骤。action=create_upload_ticket 申请凭证,调用方按返回的 OSS 表单参数传输文件,再用 confirm_upload 确认对象;每次调用只执行一个步骤。工具不接受本地路径或 base64、不代传文件。owner 由 API Key 决定。确认对象不等于登记项目资源、素材库记录或创建画布图层,需要时另外登记。" + }, + "generate_image": { + "title": "生成图片", + "description": "根据 prompt 和可选参考图付费生成图片。省略 kind 为普通图;spec、character、quick-edit、ui-design、publication-material 分别用于规范、角色、参考变体、UI 设计和宣发,不支持 scene。screenColor 是生成/抠图使用的纯色背景,不保证最终保留底色。使用支持的项目、素材库和 canvasCompletion 字段指定目标。必填稳定 idempotencyKey;返回异步任务 ID,用 check_generation 查询完成结果和告警。" + }, + "modify_image": { + "title": "修改图片", + "description": "付费修改图片。action=edit 定向修改,sourceReferenceId 必须为已登记项目资源或素材 ID,不能用 objectKey/URL;variation 参考生成新版本,使用 referenceImageSrcs,固定 kind=quick-edit,无需传 kind,与 generate_image 的 quick-edit 相同;remove_background 对静态图片去背景,sourceImageSrc 可用所属 objectKey、资源或素材 ID。去背景默认 complex,只有 flat 可传非 null screenColor。edit/去背景的原位替换遵守 projectId+targetLayerId 来源绑定;变体不承诺原位替换。顶层 idempotencyKey 必填,异步结果用 check_generation 查询。" + }, + "generate_icon_spritesheet": { + "title": "生成图标图集", + "description": "按已登记 icon-spec 规范 referenceId 与 iconDescriptions 付费生成图集并尝试切片;主 referenceId 不能用 objectKey/URL 或辅助 referenceImageSrcs 替代。sliceMode 必填无默认:grid 提供需求指定的 gridX/gridY(1–32),不传 sliceCount;connected-components 可传 sliceCount(1–256),不传 gridX/gridY。这不是已有图片通用裁切。稳定 idempotencyKey 必填,异步查询 check_generation;保留 warning 与 sliceWarning,图集成功不等于切片成功。" + }, + "extract_ui_assets": { + "title": "提取 UI 素材", + "description": "以 sourceImageSrc 中的 UI 设计图为参考,付费生成组件素材图集并尝试切片;aspectRatio、imageSize 必填,可选 spritesheetLabel 命名。包含生成步骤,不保证逐像素原样裁出。明确图标清单和规范生成用 generate_icon_spritesheet。稳定 idempotencyKey 必填,异步查询 check_generation,分别判断完整图集与切片结果并保留告警。" + }, + "generate_character_animation": { + "title": "生成角色动画", + "description": "根据角色源图和 promptText 付费生成动画预览与帧序列。sourceLayerId、sourceImageSrc、sourceWidth、sourceHeight 必须来自真实资源,不伪造;按 schema 提供输出参数。不能编辑已有动画文件。稳定 idempotencyKey 必填,异步查询 check_generation;消费正式帧序列结果,不用第一帧重复登记动画。" + }, + "generate_video": { + "title": "生成视频", + "description": "根据文字和模型支持的参考图片、视频或音频付费生成视频片段。prompt、model、aspectRatio、durationSeconds、resolution、mode、sound 必填;mode 是视频业务模式。参考媒体、声音与输出组合依所选模型的现有能力,不能假定所有模型均支持。稳定 idempotencyKey 必填,返回异步任务 ID,通过 check_generation 查询。" + }, + "generate_audio": { + "title": "生成音频", + "description": "付费生成音效或背景音乐。action=sound_effect 用于短声音、环境声和交互反馈,必填 prompt,duration 省略/null 自动,手动 0.5–30 秒;background_music 用于配乐,必填 gptDescriptionPrompt、makeInstrumental,保留各分支原字段。顶层稳定 idempotencyKey 必填,返回异步任务 ID,通过 check_generation 查询结果。" + }, + "edit_canvas": { + "title": "编辑画布", + "description": "action=get 读取 projectId 对应项目;save_layout 基于最新 expectedRevision 保存完整 viewport 和 layers,不是单图层 patch,冲突时重新读取并处理;register_resource 登记已有媒体为项目资源,可选顶层 idempotencyKey,只登记资源不自动创建图层。正常生成结果落画布优先使用生成工具的 canvasCompletion。返回现有业务结果或真实版本冲突。" + }, + "organize_asset_library": { + "title": "整理素材库", + "description": "整理素材文件夹和记录。action=create_folder 新建文件夹(可选顶层 idempotencyKey);update_folder 修改 label/collapsed;create_asset 登记已有媒体元数据,不上传或生成,不支持幂等键;update_asset 修改名称或通过 folderId 移动素材。已经入库的生成结果不要重复登记。删除用 delete_resources。" + }, + "check_generation": { + "title": "查看生成进度与结果", + "description": "按 operationId 查询一次生成任务状态,不阻塞等待完成。queued/running 按 pollAfterMs 再查;completed 才消费 result,保留 warning、sliceWarning;failed 如实返回安全错误。跨账号与不存在任务同样不可见。已知任务 ID 时直接查询;提交响应丢失而没有 ID 时,用原 API、原请求和原 idempotencyKey 重试提交取得受理结果,不换键重提。查询超时不改变任务状态。" + }, + "delete_resources": { + "title": "删除资源", + "description": "按精确 ID 删除,调用前明确目标范围并取得相应用户授权。action=delete_project 删除项目并级联清理默认画布和项目资源元数据;delete_folder 将其素材移到默认文件夹后删除文件夹,默认文件夹不可删除;delete_asset 删除素材记录并处理关联精选审核状态。文件夹/素材记录删除不等于删除 OSS 文件。不支持批量、模糊匹配或按名称删除。" + } +} diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index 2042fa3d6..07c129681 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -25,6 +25,8 @@ use tower::ServiceExt; use crate::{modules, request_context::RequestContext, state::AppState}; +mod semantic; + const OPENAPI_JSON: &str = include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); const SKILL_MD: &str = @@ -125,7 +127,11 @@ impl ServerHandler for GenarrativeExternalMcp { _context: McpRequestContext, ) -> Result { Ok(ListToolsResult::with_all_items( - MCP_OPERATIONS.iter().map(mcp_operation_tool).collect(), + MCP_OPERATIONS + .iter() + .map(mcp_operation_tool) + .chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone())) + .collect(), )) } @@ -134,6 +140,7 @@ impl ServerHandler for GenarrativeExternalMcp { .iter() .find(|operation| operation.tool_name == name) .map(mcp_operation_tool) + .or_else(|| semantic::find(name).map(|entry| entry.tool.clone())) } async fn call_tool( @@ -141,12 +148,30 @@ impl ServerHandler for GenarrativeExternalMcp { request: CallToolRequestParams, context: McpRequestContext, ) -> Result { + if let Some(tool) = semantic::find(request.name.as_ref()) { + let result = match tool.prepare(request.arguments.unwrap_or_default()) { + Ok(call) => { + dispatch_operation( + call.operation, + call.arguments, + &context, + call.optional_idempotency_key, + ) + .await + } + Err(error) => Err(error), + }; + return Ok(match result { + Ok(value) => CallToolResult::structured(value), + Err(value) => CallToolResult::structured_error(value), + }); + } let operation = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == request.name.as_ref()) .ok_or_else(|| ErrorData::invalid_params("未知的陶泥儿外部 API 工具", None))?; let arguments = request.arguments.unwrap_or_default(); - match dispatch_operation(operation, arguments, &context).await { + match dispatch_operation(operation, arguments, &context, None).await { Ok(value) => Ok(CallToolResult::structured(value)), Err(value) => Ok(CallToolResult::structured_error(value)), } @@ -475,6 +500,7 @@ async fn dispatch_operation( operation: &McpOperation, arguments: Map, context: &McpRequestContext, + optional_idempotency_key: Option, ) -> Result { validate_required_body(operation, &arguments)?; @@ -498,6 +524,51 @@ async fn dispatch_operation( .cloned() .ok_or_else(|| json!({"error": "Authorization 请求头缺失"}))?; + let request = build_operation_request( + operation, + &arguments, + authorization, + request_context, + optional_idempotency_key, + )?; + let response = modules::external_api::router(state.clone()) + .with_state(state) + .oneshot(request) + .await + .unwrap_or_else(|never| match never {}); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .map_err(|_| json!({"error": "读取外部 API 响应失败"}))? + .to_bytes(); + if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES { + return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"})); + } + let payload = serde_json::from_slice::(&bytes).unwrap_or_else(|_| { + json!({ + "status": status.as_u16(), + "message": "外部 API 返回了非 JSON 响应" + }) + }); + if status.is_success() { + Ok(unwrap_external_api_success_payload(payload)) + } else { + Err(json!({ + "status": status.as_u16(), + "response": payload, + })) + } +} + +fn build_operation_request( + operation: &McpOperation, + arguments: &Map, + authorization: axum::http::HeaderValue, + request_context: RequestContext, + optional_idempotency_key: Option, +) -> Result, Value> { let mut path = operation.path_template.clone(); if let Some(path_parameters) = arguments.get("pathParameters").and_then(Value::as_object) { for (name, value) in path_parameters { @@ -532,37 +603,12 @@ async fn dispatch_operation( "application/json".parse().expect("valid content type"), ); } - apply_operation_headers(operation, &arguments, request.headers_mut())?; + apply_operation_headers(operation, arguments, request.headers_mut())?; + if let Some(key) = optional_idempotency_key { + request.headers_mut().insert("idempotency-key", key); + } - let response = modules::external_api::router(state.clone()) - .with_state(state) - .oneshot(request) - .await - .unwrap_or_else(|never| match never {}); - let status = response.status(); - let bytes = response - .into_body() - .collect() - .await - .map_err(|_| json!({"error": "读取外部 API 响应失败"}))? - .to_bytes(); - if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES { - return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"})); - } - let payload = serde_json::from_slice::(&bytes).unwrap_or_else(|_| { - json!({ - "status": status.as_u16(), - "message": "外部 API 返回了非 JSON 响应" - }) - }); - if status.is_success() { - Ok(unwrap_external_api_success_payload(payload)) - } else { - Err(json!({ - "status": status.as_u16(), - "response": payload, - })) - } + Ok(request) } fn apply_operation_headers( @@ -977,15 +1023,16 @@ mod tests { let tools = MCP_OPERATIONS .iter() .map(mcp_operation_tool) + .chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone())) .collect::>(); let serialized = serde_json::to_vec(&tools).expect("tool catalog should serialize"); assert!(serialized.len() < 512 * 1024); - for operation in MCP_OPERATIONS.iter() { - let serialized = serde_json::to_string(&operation.input_schema) + for tool in tools { + let serialized = serde_json::to_string(&tool.input_schema) .expect("tool input schema should serialize"); - assert!(serialized.len() < 64 * 1024, "{}", operation.tool_name); - assert!(!serialized.contains("\"$ref\""), "{}", operation.tool_name); - assert_eq!(operation.input_schema.get("type"), Some(&json!("object"))); + assert!(serialized.len() < 64 * 1024, "{}", tool.name); + assert!(!serialized.contains("\"$ref\""), "{}", tool.name); + assert_eq!(tool.input_schema.get("type"), Some(&json!("object"))); } } @@ -1024,6 +1071,269 @@ mod tests { })), json!({"operationId": "task-1", "status": "queued"}) ); + let result = json!({ + "operationId": "task-1", "status": "completed", + "result": {"objectKey": "media/sheet.png", "warning": {"code": "source_preserved"}, + "sliceWarning": {"code": "slice_failed"}, "project": {"revision": 7}} + }); + assert_eq!( + unwrap_external_api_success_payload(json!({"ok": true, "data": result})), + result + ); + } + + fn rpc_request(method: &str, params: Value) -> Request { + Request::builder() + .method(Method::POST) + .uri("/api/external/v1/mcp") + .header(HOST, "localhost") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json, text/event-stream") + .header("mcp-protocol-version", "2025-11-25") + .body(Body::from( + json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string(), + )) + .unwrap() + } + + async fn rpc_payload(response: axum::response::Response) -> Value { + assert_eq!(response.status(), StatusCode::OK); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() + } + + #[tokio::test] + async fn semantic_catalog_appends_tools_without_changing_legacy_definitions() { + let payload = rpc_payload( + service() + .oneshot(rpc_request("tools/list", json!({}))) + .await + .unwrap() + .map(Body::new), + ) + .await; + let tools = payload["result"]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), MCP_OPERATIONS.len() + 15); + for op in MCP_OPERATIONS.iter() { + let expected = serde_json::to_value(mcp_operation_tool(op)).unwrap(); + assert_eq!( + tools.iter().find(|tool| tool["name"] == op.tool_name), + Some(&expected) + ); + } + for entry in semantic::TOOLS.iter() { + assert_eq!( + GenarrativeExternalMcp.get_tool(&entry.tool.name), + Some(entry.tool.clone()) + ); + } + } + + #[tokio::test] + async fn semantic_invalid_arguments_fail_before_http_context_or_side_effects() { + for (name, arguments) in [ + ( + "modify_image", + json!({"action": "edit", "input": {"prompt": "修改", "sourceImageSrc": "wrong-reference"}, "idempotencyKey": "test"}), + ), + ( + "delete_resources", + json!({"action": "delete_project", "input": {}}), + ), + ( + "manage_canvas_projects", + json!({"action": "rename", "input": {"projectId": "project", "title": "新名"}, "idempotencyKey": "not-supported"}), + ), + ("generate_image", json!({"prompt": "test"})), + ] { + let payload = rpc_payload( + service() + .oneshot(rpc_request( + "tools/call", + json!({"name": name, "arguments": arguments}), + )) + .await + .unwrap() + .map(Body::new), + ) + .await; + assert_eq!(payload["result"]["isError"], true, "{name}: {payload}"); + assert!(payload["result"]["structuredContent"]["error"].is_string()); + assert!(!payload.to_string().contains("上下文缺失")); + } + } + + #[tokio::test] + async fn semantic_adapter_builds_real_rest_paths_bodies_and_optional_headers() { + for (name, args, method, path, body, key) in [ + ( + "manage_canvas_projects", + json!({"action":"create","input":{},"idempotencyKey":"create-project"}), + Method::POST, + "/api/external/v1/editor/projects", + json!({}), + Some("create-project"), + ), + ( + "manage_canvas_projects", + json!({"action":"rename","input":{"projectId":"project/a","title":"新名"}}), + Method::PATCH, + "/api/external/v1/editor/projects/project%2Fa/metadata", + json!({"title":"新名"}), + None, + ), + ( + "find_assets", + json!({"action":"get_download_url","input":{"objectKey":"images/a b.png","expireSeconds":60}}), + Method::GET, + "/api/external/v1/assets/read-url?expireSeconds=60&objectKey=images%2Fa+b.png", + Value::Null, + None, + ), + ( + "find_canvas_projects", + json!({"action":"list","input":{}}), + Method::GET, + "/api/external/v1/editor/projects?view=summary", + Value::Null, + None, + ), + ( + "modify_image", + json!({"action":"variation","input":{"prompt":"变体","referenceImageSrcs":["ref"]},"idempotencyKey":"same-generation"}), + Method::POST, + "/api/external/v1/editor/images/generations", + json!({"prompt":"变体","referenceImageSrcs":["ref"],"kind":"quick-edit"}), + Some("same-generation"), + ), + ] { + let call = semantic::find(name) + .unwrap() + .prepare(args.as_object().unwrap().clone()) + .unwrap(); + let context = RequestContext::new( + "test-request".into(), + "POST /api/external/v1/mcp".into(), + std::time::Duration::ZERO, + false, + ); + let request = build_operation_request( + call.operation, + &call.arguments, + "Bearer fixture".parse().unwrap(), + context, + call.optional_idempotency_key, + ) + .unwrap(); + assert_eq!(request.method(), method); + assert_eq!(request.uri().to_string(), path); + assert_eq!(request.headers()[AUTHORIZATION], "Bearer fixture"); + assert_eq!( + request + .headers() + .get("idempotency-key") + .map(|v| v.to_str().unwrap()), + key + ); + assert_eq!( + request + .extensions() + .get::() + .unwrap() + .request_id(), + "test-request" + ); + let bytes = request.into_body().collect().await.unwrap().to_bytes(); + if body.is_null() { + assert!(bytes.is_empty()); + } else { + assert_eq!(serde_json::from_slice::(&bytes).unwrap(), body); + } + } + let operation = MCP_OPERATIONS + .iter() + .find(|op| op.operation_id == "createEditorProject") + .unwrap(); + let mut headers = HeaderMap::new(); + apply_operation_headers( + operation, + &json!({"idempotencyKey": "legacy-ignored"}) + .as_object() + .unwrap() + .clone(), + &mut headers, + ) + .unwrap(); + assert!( + headers.get("idempotency-key").is_none(), + "old optional-header behavior must remain unchanged" + ); + } + + #[tokio::test] + async fn semantic_calls_reuse_rest_scope_checks_and_structured_errors() { + use crate::state::external_api_auth::ExternalApiKeyAuthenticator; + use futures_util::future::BoxFuture; + use spacetime_client::{ + ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClientError, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct NoScopes(AtomicUsize); + impl ExternalApiKeyAuthenticator for NoScopes { + fn authenticate_external_api_key( + &self, + _: ExternalApiKeyAuthenticateRecordInput, + ) -> BoxFuture<'_, Result> { + self.0.fetch_add(1, Ordering::Relaxed); + Box::pin(async { + Ok(ExternalApiKeyRecord { + key_id: "fixture-key".into(), + owner_user_id: "owner-from-store".into(), + name: "测试".into(), + key_prefix: "tnr_sk_fixture".into(), + scopes: vec![], + created_at: "0.000000Z".into(), + last_used_at: None, + revoked_at: None, + updated_at: "0.000000Z".into(), + }) + }) + } + } + let auth = Arc::new(NoScopes(AtomicUsize::new(0))); + let state = AppState::new(AppConfig::default()) + .unwrap() + .with_external_api_auth_state(crate::state::ExternalApiAuthState::new(auth.clone())); + let router = modules::external_api::router(state.clone()) + .with_state(state) + .layer(middleware::from_fn(attach_request_context)); + for (name, arguments) in [ + ( + "delete_resources", + json!({"action":"delete_project","input":{"projectId":"fixture-project"}}), + ), + ( + "delete_editor_project", + json!({"pathParameters":{"projectId":"fixture-project"}}), + ), + ] { + let mut request = rpc_request("tools/call", json!({"name":name,"arguments":arguments})); + request + .headers_mut() + .insert(AUTHORIZATION, "Bearer tnr_sk_fixture".parse().unwrap()); + let payload = rpc_payload(router.clone().oneshot(request).await.unwrap()).await; + assert_eq!(payload["result"]["isError"], true, "{payload}"); + assert_eq!( + payload["result"]["structuredContent"]["status"], 403, + "{payload}" + ); + assert!(!payload.to_string().contains("owner-from-store")); + } + assert_eq!( + auth.0.load(Ordering::Relaxed), + 4, + "outer MCP and inner REST both authenticate" + ); } #[tokio::test] diff --git a/server-rs/crates/api-server/src/external_mcp/semantic.rs b/server-rs/crates/api-server/src/external_mcp/semantic.rs new file mode 100644 index 000000000..73bbc65d4 --- /dev/null +++ b/server-rs/crates/api-server/src/external_mcp/semantic.rs @@ -0,0 +1,431 @@ +//! 语义入口只负责操作选择和参数位置转换,业务校验与副作用仍由 External router 承担。 + +use super::*; +use axum::http::HeaderValue; + +pub(super) static TOOLS: LazyLock> = LazyLock::new(build_tools); + +pub(super) struct SemanticTool { + pub(super) tool: Tool, + actions: Vec, +} + +struct Action { + name: Option<&'static str>, + operation: &'static McpOperation, + input_schema: Value, + key_schema: Option, + fixed_body: Map, +} + +pub(super) struct PreparedCall { + pub(super) operation: &'static McpOperation, + pub(super) arguments: Map, + pub(super) optional_idempotency_key: Option, +} + +pub(super) fn find(name: &str) -> Option<&'static SemanticTool> { + TOOLS.iter().find(|entry| entry.tool.name == name) +} + +fn build_tools() -> Vec { + let openapi: Value = serde_json::from_str(OPENAPI_JSON).expect("embedded OpenAPI must parse"); + let descriptions: Value = serde_json::from_str(include_str!( + "../../prompts/external_mcp/semantic_tools.json" + )) + .expect("semantic tool descriptions must parse"); + let definitions: &[(&str, &[(&str, &str)])] = &[ + ( + "find_canvas_projects", + &[ + ("list", "listEditorProjects"), + ("recent", "loadRecentEditorProject"), + ("get", "getEditorProject"), + ], + ), + ( + "manage_canvas_projects", + &[ + ("create", "createEditorProject"), + ("rename", "renameEditorProject"), + ], + ), + ( + "find_assets", + &[ + ("list_library", "getEditorAssetLibrary"), + ("get_project_resources", "getEditorProject"), + ("get_download_url", "getExternalAssetReadUrl"), + ], + ), + ( + "prepare_asset_upload", + &[ + ("create_upload_ticket", "createExternalDirectUploadTicket"), + ("confirm_upload", "confirmExternalAssetObject"), + ], + ), + ("generate_image", &[("", "generateExternalEditorImage")]), + ( + "modify_image", + &[ + ("edit", "editExternalEditorImage"), + ("variation", "generateExternalEditorImage"), + ("remove_background", "removeExternalEditorImageBackground"), + ], + ), + ( + "generate_icon_spritesheet", + &[("", "generateExternalEditorIconSpritesheet")], + ), + ( + "extract_ui_assets", + &[("", "extractExternalEditorUiDesignAssets")], + ), + ( + "generate_character_animation", + &[("", "generateExternalEditorCharacterAnimation")], + ), + ("generate_video", &[("", "generateExternalEditorVideo")]), + ( + "generate_audio", + &[ + ("sound_effect", "generateExternalEditorSoundEffect"), + ("background_music", "generateExternalEditorBackgroundMusic"), + ], + ), + ( + "edit_canvas", + &[ + ("get", "getEditorProject"), + ("save_layout", "saveEditorProjectCanvas"), + ("register_resource", "createEditorProjectResource"), + ], + ), + ( + "organize_asset_library", + &[ + ("create_folder", "createEditorAssetFolder"), + ("update_folder", "updateEditorAssetFolder"), + ("create_asset", "createEditorAsset"), + ("update_asset", "updateEditorAsset"), + ], + ), + ( + "check_generation", + &[("", "getExternalEditorGenerationJob")], + ), + ( + "delete_resources", + &[ + ("delete_project", "deleteEditorProject"), + ("delete_folder", "deleteEditorAssetFolder"), + ("delete_asset", "deleteEditorAsset"), + ], + ), + ]; + definitions + .iter() + .map(|(name, operations)| { + let actions = operations + .iter() + .map(|(action, operation)| { + Action::new((!action.is_empty()).then_some(*action), operation, &openapi) + }) + .collect::>(); + let read_only = actions.iter().all(|a| a.operation.method == Method::GET); + let generation = actions.iter().any(|a| a.operation.requires_idempotency_key); + let destructive = actions.iter().any(|a| { + a.operation.method == Method::DELETE + || a.operation.method == Method::PATCH + || a.input_schema["properties"] + .get("canvasCompletion") + .is_some() + || a.input_schema["properties"].get("targetLayerId").is_some() + }); + let schema = tool_schema(&actions); + let mut tool = Tool::new( + name.to_string(), + descriptions[name]["description"] + .as_str() + .expect("tool description") + .to_string(), + Arc::new(schema.as_object().expect("object schema").clone()), + ); + tool.title = Some( + descriptions[name]["title"] + .as_str() + .expect("tool title") + .to_string(), + ); + tool.annotations = Some( + ToolAnnotations::new() + .read_only(read_only) + .destructive(destructive) + .idempotent( + read_only || actions.iter().all(|a| a.operation.requires_idempotency_key), + ) + .open_world( + generation || *name == "prepare_asset_upload" || *name == "find_assets", + ), + ); + SemanticTool { tool, actions } + }) + .collect() +} + +impl Action { + fn new(name: Option<&'static str>, operation_id: &str, openapi: &Value) -> Self { + let operation = MCP_OPERATIONS + .iter() + .find(|op| op.operation_id == operation_id) + .expect("semantic tools must map to existing operations"); + let wrapped = &operation.input_schema["properties"]; + // 保留 body 的 if/then/allOf 等约束;仅合并位置包装,不重建字段定义。 + let mut input_schema = wrapped + .get("body") + .cloned() + .unwrap_or_else(|| json!({"type": "object", "properties": {}})); + let mut required = input_schema + .get("required") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for location in ["pathParameters", "queryParameters"] { + if let Some(schema) = wrapped.get(location) { + for (name, field) in schema["properties"] + .as_object() + .expect("parameter properties") + { + assert!( + input_schema["properties"].get(name).is_none(), + "ambiguous field {name}" + ); + input_schema["properties"][name] = inline_openapi_schema(openapi, field, 0); + } + required.extend( + schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .cloned(), + ); + } + } + let mut fixed_body = Map::new(); + if name == Some("variation") { + input_schema["properties"] + .as_object_mut() + .unwrap() + .remove("kind"); + required.retain(|field| field != "kind"); + fixed_body.insert("kind".into(), json!("quick-edit")); + } + if operation_id == "confirmExternalAssetObject" { + input_schema["properties"] + .as_object_mut() + .unwrap() + .remove("ownerUserId"); + } + input_schema["required"] = Value::Array(required); + input_schema["additionalProperties"] = json!(false); + let path = operation.path_template.split('?').next().unwrap(); + let path_item = &openapi["paths"][path]; + let rest = &path_item[operation.method.as_str().to_ascii_lowercase()]; + let key_schema = path_item + .get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .chain( + rest.get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten(), + ) + .filter_map(|p| resolve_openapi_reference(openapi, p)) + .find(|p| p["in"] == "header" && p["name"] == "Idempotency-Key") + .map(|p| { + let mut schema = inline_openapi_schema(openapi, &p["schema"], 0); + if let Some(description) = p.get("description") { + schema["description"] = description.clone(); + } + schema + }); + Self { + name, + operation, + input_schema, + key_schema, + fixed_body, + } + } + + fn call_schema(&self) -> Value { + let mut schema = match self.name { + Some(name) => json!({ + "type": "object", + "properties": {"action": {"type": "string", "const": name}, "input": self.input_schema}, + "required": ["action", "input"], + "additionalProperties": false + }), + None => self.input_schema.clone(), + }; + if let Some(key) = &self.key_schema { + schema["properties"]["idempotencyKey"] = key.clone(); + if self.operation.requires_idempotency_key { + schema["required"] + .as_array_mut() + .unwrap() + .push(json!("idempotencyKey")); + } + } + schema + } +} + +fn tool_schema(actions: &[Action]) -> Value { + if actions[0].name.is_none() { + return actions[0].call_schema(); + } + let mut schema = json!({ + "type": "object", + "properties": { + "action": {"type": "string", "enum": actions.iter().map(|a| a.name.unwrap()).collect::>()}, + "input": {"type": "object"} + }, + "required": ["action", "input"], + "additionalProperties": false, + "oneOf": actions.iter().map(Action::call_schema).collect::>() + }); + if let Some(key) = actions.iter().find_map(|a| a.key_schema.as_ref()) { + schema["properties"]["idempotencyKey"] = key.clone(); + } + schema +} + +impl SemanticTool { + pub(super) fn prepare(&self, mut arguments: Map) -> Result { + let action = if self.actions[0].name.is_none() { + &self.actions[0] + } else { + let name = arguments + .get("action") + .and_then(Value::as_str) + .ok_or_else(|| json!({"error": "必须提供字符串 action"}))?; + self.actions + .iter() + .find(|a| a.name == Some(name)) + .ok_or_else(|| json!({"error": "未知 action"}))? + }; + validate_fields(&action.call_schema(), &arguments)?; + let key = arguments.remove("idempotencyKey"); + let mut optional_idempotency_key = None; + if let Some(key) = &key { + let key = key + .as_str() + .ok_or_else(|| json!({"error": "idempotencyKey 必须是字符串"}))?; + if key.is_empty() || key.len() > 128 || !key.bytes().all(|c| (b'!'..=b'~').contains(&c)) + { + return Err(json!({"error": "idempotencyKey 必须为 1–128 个非空白 ASCII 字符"})); + } + if !action.operation.requires_idempotency_key { + optional_idempotency_key = Some( + HeaderValue::from_str(key) + .map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?, + ); + } + } + let input = if action.name.is_some() { + arguments + .remove("input") + .and_then(|value| value.as_object().cloned()) + .ok_or_else(|| json!({"error": "input 必须是 JSON 对象"}))? + } else { + arguments + }; + validate_fields(&action.input_schema, &input)?; + let wrapped = &action.operation.input_schema["properties"]; + let mut mapped = Map::new(); + for location in ["pathParameters", "queryParameters", "body"] { + if let Some(schema) = wrapped.get(location) { + let mut fields = input + .iter() + .filter(|(name, _)| schema["properties"].get(*name).is_some()) + .map(|(name, value)| (name.clone(), value.clone())) + .collect::>(); + if location == "body" { + fields.extend(action.fixed_body.clone()); + } + // 有请求体的操作始终发送对象,包括无字段的项目创建。 + if location == "body" || !fields.is_empty() { + mapped.insert(location.into(), Value::Object(fields)); + } + } + } + if action.operation.requires_idempotency_key { + if let Some(key) = key { + mapped.insert("idempotencyKey".into(), key); + } + } + Ok(PreparedCall { + operation: action.operation, + arguments: mapped, + optional_idempotency_key, + }) + } +} + +// 只校验适配层结构和直接字段,不实现第二套业务 schema 验证器。 +// 嵌套字段与跨字段条件在现有 REST DTO/业务入口中校验,完整 schema 仍向客户端提供。 +fn validate_fields(schema: &Value, input: &Map) -> Result<(), Value> { + let properties = schema["properties"] + .as_object() + .expect("input schema properties"); + for name in schema["required"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + if !input.contains_key(name) { + return Err(json!({"error": "缺少必填字段", "field": name})); + } + } + for (name, value) in input { + let field = properties + .get(name) + .ok_or_else(|| json!({"error": "当前操作不接受此字段", "field": name}))?; + let matches_type = |kind: &str| match kind { + "string" => value.is_string(), + "object" => value.is_object(), + "array" => value.is_array(), + "boolean" => value.is_boolean(), + "number" => value.is_number(), + "integer" => { + value.is_i64() || value.is_u64() || value.as_f64().is_some_and(|v| v.fract() == 0.0) + } + "null" => value.is_null(), + _ => true, + }; + let valid_type = match &field["type"] { + Value::String(kind) => matches_type(kind), + Value::Array(kinds) => kinds.iter().filter_map(Value::as_str).any(matches_type), + _ => true, + }; + if !valid_type + || field + .get("enum") + .and_then(Value::as_array) + .is_some_and(|values| !values.contains(value)) + || field.get("const").is_some_and(|expected| expected != value) + { + return Err(json!({"error": "字段类型或取值不符合当前操作", "field": name})); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs new file mode 100644 index 000000000..725629993 --- /dev/null +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -0,0 +1,666 @@ +use super::*; +use std::collections::BTreeSet; + +fn object(value: Value) -> Map { + value + .as_object() + .expect("test input must be an object") + .clone() +} + +fn prepare( + tool_name: &str, + action: Option<&str>, + input: Value, + key: Option<&str>, +) -> Result { + let mut arguments = if let Some(action) = action { + object(json!({"action": action, "input": input})) + } else { + object(input) + }; + if let Some(key) = key { + arguments.insert("idempotencyKey".into(), json!(key)); + } + find(tool_name) + .expect("semantic tool must exist") + .prepare(arguments) +} + +#[test] +fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { + assert_eq!(TOOLS.len(), 15); + assert_eq!(MCP_OPERATIONS.len(), 29); + let legacy = MCP_OPERATIONS + .iter() + .map(mcp_operation_tool) + .collect::>(); + let mut names = BTreeSet::new(); + for tool in &legacy { + assert!(names.insert(tool.name.to_string()), "duplicate legacy tool"); + } + for entry in TOOLS.iter() { + assert!( + names.insert(entry.tool.name.to_string()), + "semantic tool shadows a legacy tool" + ); + assert!( + entry + .tool + .description + .as_deref() + .is_some_and(|text| !text.is_empty()) + ); + } + assert_eq!(names.len(), 44); +} + +#[test] +fn every_semantic_action_maps_to_one_existing_operation_and_correct_parameter_location() { + // (tool, action, operationId, input, pathParameters, queryParameters, body) + let cases = [ + ( + "find_canvas_projects", + Some("list"), + "listEditorProjects", + json!({}), + Value::Null, + Value::Null, + Value::Null, + ), + ( + "find_canvas_projects", + Some("recent"), + "loadRecentEditorProject", + json!({}), + Value::Null, + Value::Null, + Value::Null, + ), + ( + "find_canvas_projects", + Some("get"), + "getEditorProject", + json!({"projectId":"project-1"}), + json!({"projectId":"project-1"}), + Value::Null, + Value::Null, + ), + ( + "manage_canvas_projects", + Some("create"), + "createEditorProject", + json!({"title":"项目"}), + Value::Null, + Value::Null, + json!({"title":"项目"}), + ), + ( + "manage_canvas_projects", + Some("rename"), + "renameEditorProject", + json!({"projectId":"project-1","title":"新名称"}), + json!({"projectId":"project-1"}), + Value::Null, + json!({"title":"新名称"}), + ), + ( + "find_assets", + Some("list_library"), + "getEditorAssetLibrary", + json!({}), + Value::Null, + Value::Null, + Value::Null, + ), + ( + "find_assets", + Some("get_project_resources"), + "getEditorProject", + json!({"projectId":"project-1"}), + json!({"projectId":"project-1"}), + Value::Null, + Value::Null, + ), + ( + "find_assets", + Some("get_download_url"), + "getExternalAssetReadUrl", + json!({"objectKey":"assets/a.png","expireSeconds":60}), + Value::Null, + json!({"objectKey":"assets/a.png","expireSeconds":60}), + Value::Null, + ), + ( + "prepare_asset_upload", + Some("create_upload_ticket"), + "createExternalDirectUploadTicket", + json!({"legacyPrefix":"images","fileName":"a.png"}), + Value::Null, + Value::Null, + json!({"legacyPrefix":"images","fileName":"a.png"}), + ), + ( + "prepare_asset_upload", + Some("confirm_upload"), + "confirmExternalAssetObject", + json!({"objectKey":"assets/a.png","assetKind":"image"}), + Value::Null, + Value::Null, + json!({"objectKey":"assets/a.png","assetKind":"image"}), + ), + ( + "generate_image", + None, + "generateExternalEditorImage", + json!({"prompt":"城堡","kind":"spec"}), + Value::Null, + Value::Null, + json!({"prompt":"城堡","kind":"spec"}), + ), + ( + "modify_image", + Some("edit"), + "editExternalEditorImage", + json!({"sourceReferenceId":"resource-1","prompt":"红色衣服"}), + Value::Null, + Value::Null, + json!({"sourceReferenceId":"resource-1","prompt":"红色衣服"}), + ), + ( + "modify_image", + Some("variation"), + "generateExternalEditorImage", + json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"]}), + Value::Null, + Value::Null, + json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"],"kind":"quick-edit"}), + ), + ( + "modify_image", + Some("remove_background"), + "removeExternalEditorImageBackground", + json!({"sourceImageSrc":"assets/a.png","backgroundMode":"flat","screenColor":"auto"}), + Value::Null, + Value::Null, + json!({"sourceImageSrc":"assets/a.png","backgroundMode":"flat","screenColor":"auto"}), + ), + ( + "generate_icon_spritesheet", + None, + "generateExternalEditorIconSpritesheet", + json!({"referenceId":"resource-1","iconDescriptions":["剑"],"sliceMode":"grid","gridX":1,"gridY":1}), + Value::Null, + Value::Null, + json!({"referenceId":"resource-1","iconDescriptions":["剑"],"sliceMode":"grid","gridX":1,"gridY":1}), + ), + ( + "extract_ui_assets", + None, + "extractExternalEditorUiDesignAssets", + json!({"sourceImageSrc":"assets/ui.png","aspectRatio":"1:1","imageSize":"1K","spritesheetLabel":"组件"}), + Value::Null, + Value::Null, + json!({"sourceImageSrc":"assets/ui.png","aspectRatio":"1:1","imageSize":"1K","spritesheetLabel":"组件"}), + ), + ( + "generate_character_animation", + None, + "generateExternalEditorCharacterAnimation", + json!({"sourceLayerId":"layer-1","sourceImageSrc":"assets/a.png","sourceWidth":512,"sourceHeight":512,"promptText":"行走","resolution":"480p","ratio":"same","frameCount":32,"durationSeconds":4,"model":"seedance2.0-fast"}), + Value::Null, + Value::Null, + json!({"sourceLayerId":"layer-1","sourceImageSrc":"assets/a.png","sourceWidth":512,"sourceHeight":512,"promptText":"行走","resolution":"480p","ratio":"same","frameCount":32,"durationSeconds":4,"model":"seedance2.0-fast"}), + ), + ( + "generate_video", + None, + "generateExternalEditorVideo", + json!({"prompt":"海浪","model":"seedance2.0-fast","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}), + Value::Null, + Value::Null, + json!({"prompt":"海浪","model":"seedance2.0-fast","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}), + ), + ( + "generate_audio", + Some("sound_effect"), + "generateExternalEditorSoundEffect", + json!({"prompt":"鸟鸣","duration":2.0}), + Value::Null, + Value::Null, + json!({"prompt":"鸟鸣","duration":2.0}), + ), + ( + "generate_audio", + Some("background_music"), + "generateExternalEditorBackgroundMusic", + json!({"gptDescriptionPrompt":"轻快音乐","makeInstrumental":true}), + Value::Null, + Value::Null, + json!({"gptDescriptionPrompt":"轻快音乐","makeInstrumental":true}), + ), + ( + "edit_canvas", + Some("get"), + "getEditorProject", + json!({"projectId":"project-1"}), + json!({"projectId":"project-1"}), + Value::Null, + Value::Null, + ), + ( + "edit_canvas", + Some("save_layout"), + "saveEditorProjectCanvas", + json!({"projectId":"project-1","viewport":{},"layers":[],"expectedRevision":7}), + json!({"projectId":"project-1"}), + Value::Null, + json!({"viewport":{},"layers":[],"expectedRevision":7}), + ), + ( + "edit_canvas", + Some("register_resource"), + "createEditorProjectResource", + json!({"projectId":"project-1","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}), + json!({"projectId":"project-1"}), + Value::Null, + json!({"imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}), + ), + ( + "organize_asset_library", + Some("create_folder"), + "createEditorAssetFolder", + json!({"label":"角色","sortOrder":2}), + Value::Null, + Value::Null, + json!({"label":"角色","sortOrder":2}), + ), + ( + "organize_asset_library", + Some("update_folder"), + "updateEditorAssetFolder", + json!({"folderId":"folder-1","collapsed":true}), + json!({"folderId":"folder-1"}), + Value::Null, + json!({"collapsed":true}), + ), + ( + "organize_asset_library", + Some("create_asset"), + "createEditorAsset", + json!({"folderId":"folder-1","label":"图","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}), + Value::Null, + Value::Null, + json!({"folderId":"folder-1","label":"图","imageSrc":"assets/a.png","width":512,"height":512,"sourceType":"image"}), + ), + ( + "organize_asset_library", + Some("update_asset"), + "updateEditorAsset", + json!({"assetId":"asset-1","folderId":"folder-2"}), + json!({"assetId":"asset-1"}), + Value::Null, + json!({"folderId":"folder-2"}), + ), + ( + "check_generation", + None, + "getExternalEditorGenerationJob", + json!({"operationId":"task-1"}), + json!({"operationId":"task-1"}), + Value::Null, + Value::Null, + ), + ( + "delete_resources", + Some("delete_project"), + "deleteEditorProject", + json!({"projectId":"project-1"}), + json!({"projectId":"project-1"}), + Value::Null, + Value::Null, + ), + ( + "delete_resources", + Some("delete_folder"), + "deleteEditorAssetFolder", + json!({"folderId":"folder-1"}), + json!({"folderId":"folder-1"}), + Value::Null, + Value::Null, + ), + ( + "delete_resources", + Some("delete_asset"), + "deleteEditorAsset", + json!({"assetId":"asset-1"}), + json!({"assetId":"asset-1"}), + Value::Null, + Value::Null, + ), + ]; + assert_eq!(cases.len(), 31); + for (tool, action, operation, input, path, query, body) in cases { + let requires_key = MCP_OPERATIONS + .iter() + .find(|op| op.operation_id == operation) + .unwrap() + .requires_idempotency_key; + let prepared = prepare( + tool, + action, + input, + requires_key.then_some("stable-request-1"), + ) + .unwrap_or_else(|error| panic!("{tool}/{action:?}: {error}")); + assert_eq!( + prepared.operation.operation_id, operation, + "{tool}/{action:?}" + ); + for (location, expected) in [ + ("pathParameters", path), + ("queryParameters", query), + ("body", body), + ] { + assert_eq!( + prepared + .arguments + .get(location) + .cloned() + .unwrap_or(Value::Null), + expected, + "{tool}/{action:?} {location}" + ); + } + assert_eq!( + prepared.arguments.get("idempotencyKey"), + requires_key.then_some(&json!("stable-request-1")), + "{tool}/{action:?}" + ); + assert!( + prepared.optional_idempotency_key.is_none(), + "{tool}/{action:?}" + ); + } +} + +#[test] +fn semantic_schemas_keep_existing_business_field_details() { + let image = find("generate_image").unwrap(); + let old_image = MCP_OPERATIONS + .iter() + .find(|op| op.operation_id == "generateExternalEditorImage") + .unwrap(); + for field in [ + "prompt", + "kind", + "screenColor", + "referenceImageSrcs", + "canvasCompletion", + ] { + assert_eq!( + image.tool.input_schema["properties"][field], + old_image.input_schema["properties"]["body"]["properties"][field], + "image {field}" + ); + } + let variation = find("modify_image") + .unwrap() + .actions + .iter() + .find(|action| action.name == Some("variation")) + .unwrap(); + assert!(variation.input_schema["properties"].get("kind").is_none()); + assert_eq!( + variation.input_schema["properties"]["referenceImageSrcs"], + old_image.input_schema["properties"]["body"]["properties"]["referenceImageSrcs"] + ); + let background = find("modify_image") + .unwrap() + .actions + .iter() + .find(|action| action.name == Some("remove_background")) + .unwrap(); + let old_background = MCP_OPERATIONS + .iter() + .find(|op| op.operation_id == "removeExternalEditorImageBackground") + .unwrap(); + assert_eq!( + background.input_schema["if"], + old_background.input_schema["properties"]["body"]["if"] + ); + assert_eq!( + background.input_schema["then"], + old_background.input_schema["properties"]["body"]["then"] + ); + let icon = find("generate_icon_spritesheet").unwrap(); + assert_eq!( + icon.tool.input_schema["properties"]["sliceMode"]["enum"], + json!(["connected-components", "grid"]) + ); + let animation = find("generate_character_animation").unwrap(); + assert_eq!( + animation.tool.input_schema["properties"]["model"]["const"], + json!("seedance2.0-fast") + ); + let upload = find("prepare_asset_upload") + .unwrap() + .actions + .iter() + .find(|action| action.name == Some("confirm_upload")) + .unwrap(); + assert!( + upload.input_schema["properties"] + .get("ownerUserId") + .is_none() + ); + let read_url = find("find_assets") + .unwrap() + .actions + .iter() + .find(|action| action.name == Some("get_download_url")) + .unwrap(); + assert!( + read_url.input_schema["properties"] + .get("legacyPublicPath") + .is_some() + ); +} + +#[test] +fn invalid_actions_and_fields_are_rejected_before_rest_dispatch() { + let invalid = [ + ("find_canvas_projects", Some("missing"), json!({}), None), + ("find_canvas_projects", Some("get"), json!({}), None), + ( + "find_canvas_projects", + Some("list"), + json!({"projectId":"project-1"}), + None, + ), + ( + "prepare_asset_upload", + Some("confirm_upload"), + json!({"objectKey":"a","assetKind":"image","ownerUserId":"other-user"}), + None, + ), + ( + "modify_image", + Some("variation"), + json!({"prompt":"变体","kind":"spec"}), + Some("stable-key"), + ), + ( + "modify_image", + Some("edit"), + json!({"prompt":"修改","sourceReferenceId":"resource-1","sourceImageSrc":"a"}), + Some("stable-key"), + ), + ( + "generate_audio", + Some("background_music"), + json!({"gptDescriptionPrompt":"音乐","makeInstrumental":true,"prompt":"错分支"}), + Some("stable-key"), + ), + ( + "delete_resources", + Some("delete_asset"), + json!({"folderId":"folder-1"}), + None, + ), + ( + "find_assets", + Some("get_download_url"), + json!({"objectKey":[]}), + None, + ), + ( + "generate_video", + None, + json!({"prompt":"片段","model":"not-a-model","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}), + Some("stable-key"), + ), + ]; + for (tool, action, input, key) in invalid { + assert!( + prepare(tool, action, input, key).is_err(), + "{tool}/{action:?} should reject invalid input" + ); + } + assert!( + find("find_canvas_projects") + .unwrap() + .prepare(object(json!({"action":"list","input":{},"unexpected":1}))) + .is_err() + ); + assert!(find("manage_canvas_projects").unwrap().prepare(object(json!({"action":"rename","input":{"projectId":"project-1","title":"名称"},"idempotencyKey":"unexpected"}))).is_err()); + assert!( + find("generate_image") + .unwrap() + .prepare(object( + json!({"prompt":"图","idempotencyKey":"stable-key","unexpected":1}) + )) + .is_err() + ); + let wrong_model = prepare( + "generate_video", + None, + json!({"prompt":"片段","model":"not-a-model","aspectRatio":"16:9","durationSeconds":4,"resolution":"480p","mode":"std","sound":"on"}), + Some("stable-key"), + ) + .err() + .unwrap(); + assert_eq!(wrong_model["field"], json!("model")); +} + +#[test] +fn only_three_create_actions_accept_optional_idempotency_keys() { + let cases = [ + ("manage_canvas_projects", "create", json!({"title":"项目"})), + ( + "edit_canvas", + "register_resource", + json!({"projectId":"project-1","imageSrc":"assets/a.png","width":1,"height":1,"sourceType":"image"}), + ), + ( + "organize_asset_library", + "create_folder", + json!({"label":"素材"}), + ), + ]; + for (tool, action, input) in cases { + let without = prepare(tool, Some(action), input.clone(), None).unwrap(); + assert!(without.optional_idempotency_key.is_none()); + let with = prepare(tool, Some(action), input, Some("create-1")).unwrap(); + assert_eq!( + with.optional_idempotency_key + .as_ref() + .unwrap() + .to_str() + .unwrap(), + "create-1" + ); + assert!(!with.arguments.contains_key("idempotencyKey")); + } + assert!(prepare("organize_asset_library", Some("create_asset"), json!({"folderId":"folder-1","label":"图","imageSrc":"a","width":1,"height":1,"sourceType":"image"}), Some("create-1")).is_err()); +} + +#[test] +fn all_nine_generation_operations_require_a_valid_idempotency_key() { + let actions = TOOLS + .iter() + .flat_map(|tool| tool.actions.iter()) + .filter(|action| action.operation.requires_idempotency_key) + .collect::>(); + let operation_ids = actions + .iter() + .map(|action| action.operation.operation_id.as_str()) + .collect::>(); + assert_eq!(operation_ids.len(), 9); + for action in actions { + assert!( + action.key_schema.is_some(), + "{}", + action.operation.operation_id + ); + assert!( + action.call_schema()["required"] + .as_array() + .unwrap() + .contains(&json!("idempotencyKey")), + "{}", + action.operation.operation_id + ); + } + assert!(prepare("generate_image", None, json!({"prompt":"图"}), None).is_err()); + for invalid_key in ["", "has space", "line\nbreak"] { + assert!( + prepare( + "generate_image", + None, + json!({"prompt":"图"}), + Some(invalid_key) + ) + .is_err() + ); + } + let valid = prepare( + "generate_image", + None, + json!({"prompt":"图"}), + Some("generate-1"), + ) + .unwrap(); + assert_eq!(valid.arguments["idempotencyKey"], json!("generate-1")); + assert!(valid.optional_idempotency_key.is_none()); +} + +#[test] +fn image_variation_uses_the_same_canonical_request_as_quick_edit_generation() { + let input = json!({"prompt":"另一种构图","referenceImageSrcs":["assets/a.png"],"projectId":"project-1","assetLabel":"变体"}); + let variation = prepare( + "modify_image", + Some("variation"), + input.clone(), + Some("same-request"), + ) + .unwrap(); + let mut direct_input = object(input); + direct_input.insert("kind".into(), json!("quick-edit")); + let direct = prepare( + "generate_image", + None, + Value::Object(direct_input), + Some("same-request"), + ) + .unwrap(); + assert_eq!( + variation.operation.operation_id, + direct.operation.operation_id + ); + assert_eq!(variation.arguments, direct.arguments); + assert_eq!( + variation.optional_idempotency_key, + direct.optional_idempotency_key + ); +}