Compare commits

...

6 Commits

Author SHA1 Message Date
lhk229 63a8e25f25 Merge remote-tracking branch 'origin/master' into feat/mcp-upgrade
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m36s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / Backend tests (pull_request) Successful in 3m48s
Project CI / Frontend tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 6m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m3s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m18s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / Repository checks (pull_request) Successful in 2m12s
2026-09-23 07:18:19 +00:00
lhk229 23b2a33257 完成MCP语义工具运行验收并收口文档
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
补充本地服务启动、MCP认证及新旧工具实际调用的验收证据
记录幂等、非法字段拒绝、测试数据清理及未验证范围
将已完成里程碑和实施计划归入正式技术方案并删除临时计划
2026-09-23 07:16:14 +00:00
lhk229 e0d37edff2 Merge branch 'master' into feat/mcp-upgrade
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
2026-09-23 14:28:17 +08:00
lhk229 8835311af3 合并master更新到MCP改造分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / Backend tests (pull_request) Successful in 4m8s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m43s
Project CI / Frontend tests (pull_request) Successful in 2m30s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m6s
Project CI / Native shell tests (pull_request) Successful in 6m30s
Project CI / Repository checks (pull_request) Successful in 2m11s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m27s
合入最新master的AGC提示词整理与发布反馈修复
保留现有MCP语义工具实现与全部原有入口
验证MCP定向测试和发布反馈测试及文档编码检查
2026-09-23 05:48:22 +00:00
lhk229 ecb0dc08b8 新增外部MCP语义工具并保留原有入口
新增十五个语义工具及中文说明和分支参数契约
复用现有API分派并支持新入口的可选创建幂等键
保留全部原工具和API并补充分派与兼容回归测试
同步工程文档及验收记录并注明本地数据库阻断的运行验证
2026-09-23 05:46:12 +00:00
lhk229 2ab849652e 整理外部MCP语义工具说明与参数设计
新增十五个候选工具的说明、操作映射和参数边界
明确仅复用现有External API及语义工具尚未实现的状态
补充背景色、上传、异步恢复与删除行为说明
同步文档总览、外部接口父规范和共享文档地图
2026-09-23 04:28:50 +00:00
8 changed files with 1870 additions and 37 deletions
+1
View File
@@ -21,6 +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):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
## AI 游戏创作与 Agent Runtime
@@ -21,6 +21,11 @@
3. `server-rs/crates/api-server/src/app.rs``server-rs/crates/api-server/src/modules.rs` 与对应 crate README / 源码
4. `docs/openapi/genarrative-external-v1.openapi.json`(涉及 External v1 时)
外部 MCP 语义工具设计:
1. [外部 OpenAPI 与 API Key 接入方案](../../【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md):现役托管 MCP 与 External API 合同。
2. [外部 MCP 语义工具说明与参数设计](../../technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个语义工具与全部旧工具并存,复用现有 API 分派和 schema;多功能入口使用 action/input,结果不裁剪,可选幂等只扩展新入口。resources/instructions 仍沿用原内容,线上状态按实际部署核对。
AI 游戏创作 / DirectProject / UI workflow
1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
@@ -0,0 +1,356 @@
# 外部 MCP 语义工具说明与参数设计
更新时间:`2026-09-23`
> 文档状态:`current`(工程实现合同;仓库已增加语义工具,线上可用性以实际部署版本为准)。
>
> 父规范:[外部 OpenAPI 与 API Key 接入方案](../【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)。
>
> 当前字段契约:[External v1 OpenAPI](../openapi/genarrative-external-v1.openapi.json)。本文记录工具划分、说明和参数合同;英文工具名、schema 构造、结果与兼容方式见第 5 节。
## 1. 目标与范围
在现有 API server 的托管 MCP 层增加按用户任务组织的工具。仅使用现有 External v1 已开放的能力,不直接接入尚未公开的画布功能。
工具可以存在自然的功能交集:例如参考图快速变体同时属于生成图片和修改图片,读取画布同时服务于项目查找和布局编辑。不以工具数量或 API 唯一归属为目标,不把不相关任务硬合成一个工具。
本文约定工具说明、操作选择、参数和业务边界。开发者发布页、文档存储与独立发布、CLI 改造不属于本轮范围。现有 REST 路由、DTO 和运行行为保持不变,新语义工具与全部原有工具并存。
## 2. 当前实现与目标调用链
当前 [external_mcp.rs](../../server-rs/crates/api-server/src/external_mcp.rs) 从 OpenAPI 自动建立原子工具:工具名由 `operationId` 转为 snake_casedescription 优先读取 operation 的 `description`,缺省读取 `summary`,附加 HTTP 方法和路径;参数 schema 来自 OpenAPI。服务级 instructions 提供通用工作流,resources 提供详细说明。
新工具继续部署在同一个 API server 内,复用现有分派和 External REST router
```text
Agent 调用语义工具
→ MCP 层选择对应 operation,并转换参数位置
→ 进程内调用现有 External REST router
→ 复用鉴权、scope、owner、校验、幂等、计费和业务处理
→ MCP 返回业务结果或结构化错误
```
多功能工具的一次调用只选择一个操作。工具聚合不意味着批量执行、自动连续写入或新增跨操作事务。文件上传仍由调用方完成二进制传输。
## 3. 说明与参数的共同设计
### 3.1 工具说明
每份说明包含:
1. 用途:用户希望完成什么任务时选择本工具。
2. 操作选择:多功能工具列出各 action 的用途,解释容易混淆的选择。
3. 结果:立即返回业务结果,还是返回异步任务 ID;有哪些重要的降级或结果边界。
字段格式、枚举、必填关系放入参数 schema 和字段说明,避免把全部接口校验细节堆进工具 description。付费、删除等重要影响应明确表达,但 description 和风险注解不替代后端权限校验;宿主负责结合用户已给出的授权作调用决策。
### 3.2 多功能与单功能工具
多功能工具采用 `action + input`input 必须提供对应 action 的明确字段、类型、必填条件和枚举,不能只是任意 JSON 对象。服务端在分派前校验所选操作,避免不相干分支的参数混入底层请求。
示意调用(示例 ID 仅为说明):
```json
{
"action": "edit",
"input": {
"sourceReferenceId": "<resourceId-or-assetId>",
"prompt": "把衣服改成红色",
"projectId": "<projectId>"
},
"idempotencyKey": "<stable-logical-request-key>"
}
```
单功能工具直接声明业务字段,不强行添加 action 或 input 包装。`kind``sliceMode`、视频 `mode` 等原有业务选择保留原名,它们与工具级 action 职责不同。
工具参数中的 ID 由 MCP 层放入 REST 路径或查询,业务输入放入请求体,`idempotencyKey` 转为 `Idempotency-Key` header。具体参数保留现有命名、类型和条件;下表列的是关键字段,并非完整 schema 或排他白名单。未逐项列出的可选字段按对应 OpenAPI operation 核对,不把站内 DTO 的内部字段顺带开放。
### 3.3 共用行为
- 九类生成 POST 继续要求稳定幂等键,并返回 `202 + operationId`;工具不能把受理当成生成完成。
- 项目创建、项目资源登记、文件夹创建的 REST API 支持可选幂等键,新语义入口提供并转发这些可选 header;原工具继续保持仅按 required header 推导幂等参数的行为。素材记录创建 API 不在该可选幂等合同中。
- 相同 API 能力出现在不同语义入口时,参数约束、底层请求和结果语义保持一致。重试或切换入口恢复同一逻辑请求时,保留原 API 操作、规范请求和幂等键,不因换工具名创建新任务。
- `projectId``assetFolderId``assetLabel``canvasCompletion` 等目标参数只在对应 API 支持时提供。UI 素材提取使用 `spritesheetLabel`;各生成族不共享未经核对的字段全集。
- owner 由 API Key 确定,不让 Agent 指定身份,不把 API Key 放进工具参数。
- 业务结果继续使用结构化返回;业务失败沿用安全错误语义,不把失败包装成成功。生成成功、素材登记、画布落位、切片成功分别按实际结果判断。
- 读取、写入、付费与删除应在说明和注解中准确表达。注解作用于整个工具,因此将删除独立成工具;`openWorldHint` 不等同于付费标记。
## 4. 工具说明与操作映射
以下共 15 个新增工具。删除从项目管理、素材库整理中移出,统一放入 XV。本节说明仓库实现合同,不表示线上已部署。
### I. 查找画布项目
**说明:** 查找已有画布项目,或读取指定项目的完整内容。先通过列表或最近项目确定目标,再读取详情获取画布、图层和资源。
| action | 关键参数 | 现有 operationId |
| --- | --- | --- |
| `list` | 无 | `listEditorProjects`,固定 `view=summary` |
| `recent` | 无 | `loadRecentEditorProject` |
| `get` | 必填 `projectId` | `getEditorProject` |
列表使用既有摘要视图,不能退回全量项目快照。当前接口没有分页、limit 或名称搜索参数,工具不虚构这些字段。名称匹配基于读取结果;有歧义时向用户明确候选,不静默创建替代项目。
### II. 管理画布项目
**说明:** 创建新的画布项目,或修改已有项目的名称。
| action | 关键参数 | 现有 operationId |
| --- | --- | --- |
| `create` | 可选 `title`;可选幂等键 | `createEditorProject` |
| `rename` | 必填 `projectId``title` | `renameEditorProject` |
一次创建仅创建项目,不默认追加创建同名素材文件夹。删除使用 XV。
### III. 查找与读取素材
**说明:** 查看账号素材库或指定项目中的资源,并为已有素材获取临时下载地址。
| action | 关键参数 | 现有 operationId |
| --- | --- | --- |
| `list_library` | 无 | `getEditorAssetLibrary` |
| `get_project_resources` | 必填 `projectId` | `getEditorProject` |
| `get_download_url` | `objectKey` 或兼容的 `legacyPublicPath`;可选 `expireSeconds` | `getExternalAssetReadUrl` |
项目资源查询复用项目详情,不新增资源查询 API。本次返回完整项目详情,资源位于其中的 resources;保持与项目读取相同的结构,不新增裁剪投影。
读取素材记录、获取文件地址与查看媒体内容是不同操作。工具不提供本地下载、关键词检索或相似素材搜索。临时签名 URL 用于访问媒体,不作为持久化生成引用。语义入口保留 REST 的 `legacyPublicPath` 可选查询字段;优先使用稳定 objectKey,至少提供一种来源,由现有 API 校验来源,不改 REST 契约。
### IV. 办理素材上传
**说明:** 获取文件直传凭证,并在调用方完成上传后确认素材对象。文件传输由调用方执行。
| action | 必填参数 | 常用可选参数 | 现有 operationId |
| --- | --- | --- | --- |
| `create_upload_ticket` | `legacyPrefix``fileName` | `contentType``pathSegments``maxSizeBytes` 等 | `createExternalDirectUploadTicket` |
| `confirm_upload` | `objectKey``assetKind` | `bucket``contentType``contentLength``contentHash` 等 | `confirmExternalAssetObject` |
流程为“申请凭证 → 调用方按返回的 OSS 表单直传参数上传 → 确认对象”。不把上传协议笼统写成固定 PUT。工具不接受本地路径或 base64,不代上传。对象确认后若需要项目资源或素材库记录,再调用相应登记操作;确认对象不等于完成这些登记。
`ownerUserId` 由后端账号身份决定,不作为 Agent 可选参数。
### V. 生成图片
**说明:** 根据文字和可选参考图生成图片,支持普通图片、视觉规范、角色、UI 设计、宣发素材和快速参考变体。提交后通过任务 ID 查询结果。
映射 `generateExternalEditorImage`,无需 action。
| 参数 | 约束与用途 |
| --- | --- |
| `prompt` | 必填,生成内容描述 |
| `kind` | 可选;省略表示普通图,其他用途为 `spec``character``quick-edit``ui-design``publication-material` |
| `referenceImageSrcs` | 可选参考图;具体引用和组合约束沿用 API |
| `model``aspectRatio``imageSize``size` | 可选输出配置,枚举及组合以当前 schema 为准 |
| `style` | 可选风格;适用范围沿用对应 kind 的现有处理 |
| `screenColor` | 可选纯色抠像背景色,见下文 |
| 目标与落位字段 | 按当前接口支持提供项目、素材库和画布目标 |
**背景色已经通过 API 开放。** screenColor 可传画布支持的颜色 hex,例如 `#CFEFFF`;传 `"auto"`、null 或省略,由服务端自动选择。它描述生成及后续抠图流程的纯色背景,不保证最终产物保留该底色;不能据此承诺“最终图片指定底色”或通用背景替换能力。
不额外引入与 kind 重复的 action;不添加未开放的 `scene` 生成入口。
### VI. 修改图片
**说明:** 对已有图片做定向修改、生成参考变体或移除背景。定向修改使用已登记资源;快速变体通过参考图生成新版本。提交后通过任务 ID 查询结果。
| action | 关键参数 | 现有 operationId |
| --- | --- | --- |
| `edit` | 必填 `sourceReferenceId``prompt`;其余为编辑 API 的可选字段 | `editExternalEditorImage` |
| `variation` | 必填 `prompt`;参考输入使用 `referenceImageSrcs`;其他字段复用图片生成 | `generateExternalEditorImage`,适配层固定 `kind=quick-edit` |
| `remove_background` | 必填 `sourceImageSrc`;可选 `backgroundMode`、条件允许的 `screenColor` 及目标字段 | `removeExternalEditorImageBackground` |
来源参数保留真实差异:
- edit 的 sourceReferenceId 只接受当前账号已登记的项目资源 ID 或素材 ID,不能用 objectKey 或 URL 代替。
- 去背景的 sourceImageSrc 支持当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID,只处理允许的静态图片。
- 变体的参考来源沿用生成 API;不新造含糊的统一 source 字段。其参考输入必填性、有效引用等条件沿用 quick-edit 的现有契约,不从图片生成 schema 只有 prompt 必填就推断所有用途无需前置条件。
去背景 backgroundMode 省略或 null 时默认为 `complex`。complex 做语义分割;`flat` 用于纯色背景。screenColor 仅在 flat 下可传非 null 值,支持 `auto``#RRGGBB`,省略或 null 自动检测;complex 下传非 null screenColor 会被拒绝。不能直接照搬图片生成的全部背景色组合规则。
需要原位替换时,编辑和去背景沿用各自 API 的 `projectId + targetLayerId` 来源绑定要求;不能用目标图层 ID 绕过账号归属与对象一致性校验。变体入口不额外承诺原位替换。
variation 与 V 的 kind=quick-edit 是合理交集,复用同一输入定义、底层请求和结果语义,不让调用方重复提供固定 kind。
### VII. 生成图标图集
**说明:** 根据已登记的参考规范和图标清单生成图集,并按指定方式尝试拆分独立图标。
映射 `generateExternalEditorIconSpritesheet`,无需 action。
- 必填:`referenceId``iconDescriptions``sliceMode`
- referenceId 为当前账号已登记为 `icon-spec` 的项目资源或素材 ID,不接受 objectKey 或 URL 代替。
- `sliceMode=grid`:提供需求对应的 `gridX``gridY`,各为 132,不传 sliceCount。
- `sliceMode=connected-components`:可用 `sliceCount`(1–256)约束张数,不提供 gridX/gridY。
- 可选:`referenceImageSrcs``screenColor``style`、模型、比例、尺寸及目标字段。
sliceMode 没有默认值。主规范引用使用 referenceId,不用辅助 referenceImageSrcs 替代。此工具包含生成步骤,不是任意已有图片的通用裁切工具。切片未完成时可能仍有完整图集,结果必须保留 sliceWarning。
### VIII. 提取 UI 素材
**说明:** 以已有 UI 设计图为参考,生成组件素材图集并尝试拆分,供后续界面制作使用。
映射 `extractExternalEditorUiDesignAssets`,无需 action。
- 必填:`sourceImageSrc``aspectRatio``imageSize`
- 可选:`model``referenceImageSrcs``screenColor``spritesheetLabel` 和对应目标字段。
- 结果命名沿用 spritesheetLabel,不强行改成其他生成接口的 assetLabel。
接口包含生成过程,不保证把原图中的组件逐像素原样裁出。与 VII 的区别是:VII 以规范和明确图标清单生成,VIII 以已有 UI 设计图提取组件语义。两者都须分别判断图集与切片结果。
### IX. 生成角色动画
**说明:** 根据角色源图和动作描述,生成角色动画预览与帧序列。
映射 `generateExternalEditorCharacterAnimation`,无需 action。
- 来源必填:`sourceLayerId``sourceImageSrc``sourceWidth``sourceHeight`
- 动作必填:`promptText`
- 输出必填:`resolution``ratio``frameCount``durationSeconds``model`
- 可选:`screenColor``sourceResourceId`、目标与落位字段等。
来源与尺寸先从真实资源中取得,不为简化调用伪造图层 ID 或源图尺寸。此工具不提供已有动画文件编辑。正式动画结果按现有帧序列合同消费,不重复用第一帧手工登记一份动画。
### X. 生成视频
**说明:** 根据文字和模型支持的参考图片、视频或音频生成视频片段。
映射 `generateExternalEditorVideo`,无需 action。
- 必填:`prompt``model``aspectRatio``durationSeconds``resolution``mode``sound`
- 可选参考:`referenceImageSrcs``referenceVideoSrcs``referenceAudioSrcs`
- 其他可选项:当前 API 支持的 `webSearchEnabled`、目标与落位字段等。
保留 mode 作为视频业务模式,与工具级 action 无关。参考媒体、时长、分辨率、声音和其他选项的组合以所选模型的当前契约为准,不暗示每个模型支持所有组合。
### XI. 生成音频
**说明:** 生成音效或背景音乐。短声音、环境声和交互反馈选择音效;配乐选择背景音乐。
| action | 必填参数 | 常用可选参数 | 现有 operationId |
| --- | --- | --- | --- |
| `sound_effect` | `prompt` | `duration``loop``model`、目标字段 | `generateExternalEditorSoundEffect` |
| `background_music` | `gptDescriptionPrompt``makeInstrumental` | 目标与落位字段 | `generateExternalEditorBackgroundMusic` |
当前音效 duration 是可选字段,省略或 null 表示自动,手动时长范围为 0.5–30 秒。不沿用“必须填写时长”的过期说法。两个分支保留各自提示词字段,不另外实现一套提示词翻译协议。两者均异步受理。
### XII. 编辑画布
**说明:** 读取画布、保存完整布局,或登记供图层引用的项目资源。保存布局需要基于最新画布版本。
| action | 关键参数 | 现有 operationId |
| --- | --- | --- |
| `get` | 必填 `projectId` | `getEditorProject` |
| `save_layout` | 必填 `projectId``viewport``layers``expectedRevision` | `saveEditorProjectCanvas` |
| `register_resource` | 必填 `projectId``imageSrc``width``height``sourceType`;支持可选幂等键 | `createEditorProjectResource` |
save_layout 保存完整默认画布布局,不提供只传一个图层的局部 patch 语义;expectedRevision 来自最新读取,版本冲突按现有 API 处理。
register_resource 只登记资源,并不自动创建画布图层。正常生成结果落画布优先使用生成接口的 canvasCompletion。可选 objectKey、assetObjectId、assetKind、帧序列等字段按资源登记 schema 提供,不从临时 UI 状态推导正式来源。
### XIII. 整理素材库
**说明:** 管理素材文件夹和素材记录,包括新建、改名、移动以及登记已有媒体。
| action | 关键参数 | 现有 operationId |
| --- | --- | --- |
| `create_folder` | 必填 `label`;可选 `sortOrder`、幂等键 | `createEditorAssetFolder` |
| `update_folder` | 必填 `folderId`;可选 `label``collapsed` | `updateEditorAssetFolder` |
| `create_asset` | 必填 `folderId``label``imageSrc``width``height``sourceType` | `createEditorAsset` |
| `update_asset` | 必填 `assetId`;可选 `label``folderId` | `updateEditorAsset` |
移动素材复用 update_asset 的 folderId,不额外拆一个重复操作。可选更新字段的有效性按现有 API 处理。create_asset 只登记元数据,不上传文件、不触发生成;生成接口已经完成入库时不重复登记。删除使用 XV。
### XIV. 查看生成进度与结果
**说明:** 查询一次已提交生成任务的当前状态。完成时返回结果,失败时返回错误;尚未完成时按建议间隔再次查询。
映射 `getExternalEditorGenerationJob`,无需 action;必填 `operationId`
- 每次调用只查询一次,不在 MCP 服务端长时间阻塞到任务完成。
- `queued/running`:读取进度和 pollAfterMs,继续等待。
- `completed`:消费 result,并保留 warning 与 sliceWarning 等真实降级信息。
- `failed`:返回已有脱敏错误。
- 跨账号任务与不存在任务沿用同一不可见语义。
收到 operationId 后优先直接查询。若提交响应丢失、尚未取得 operationId,不能靠查询工具凭空恢复:按现有幂等合同,复用原 API 操作、原请求和原幂等键重试以取得受理结果;不能换键重提。查询超时不改变服务端任务状态。
### XV. 删除资源
**说明:** 删除指定项目、素材文件夹或素材记录。按精确 ID 操作,调用前明确目标及删除范围,并取得对应用户授权。
| action | 必填参数 | 现有 operationId |
| --- | --- | --- |
| `delete_project` | `projectId` | `deleteEditorProject` |
| `delete_folder` | `folderId` | `deleteEditorAssetFolder` |
| `delete_asset` | `assetId` | `deleteEditorAsset` |
工具统一标记删除风险。项目删除沿用默认画布与项目资源元数据级联清理语义。文件夹删除将其素材记录移到默认文件夹,默认文件夹不能删除;素材删除删除记录并处理关联精选审核状态。文件夹和素材记录删除不等于删除底层 OSS 媒体。
不增加批量、按名称或模糊匹配删除,不用一个可伪造的 confirm 参数替代真实用户授权和后端鉴权。
## 5. 实现合同与验收
### 5.1 工具名与兼容
| 编号 | 工具名 |
| --- | --- |
| 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 客户端尚未验证时明确记录,不能将单元测试当作线上验收。
### 5.4 实现与验收结果(2026-09-23
本轮实现与本地验收完成。新增 15 个语义工具与原有 29 个工具同时可见;resources/instructions 保持原样。已完成的里程碑和实施计划收口到本节。
| 验证范围 | 证据与结果 |
| --- | --- |
| 工具与参数合同 | `cargo test --locked -p api-server external_mcp`:24 项通过,覆盖 44 个工具、31 个 action、旧定义保留、schema 展开、参数映射、错分支拒绝、必填/可选幂等和等价入口 |
| 认证与既有 API 回归 | `cargo test --locked -p api-server external_`:155 项通过,含 MCP 内外认证、scope 拒绝、跨 owner 隔离、异步及 OpenAPI 回归 |
| 编译与文本检查 | `cargo check --locked -p api-server`、定向 rustfmt、文档索引、编码和 diff 检查通过 |
| 本地服务启动 | 先通过 `npm run dev:spacetime` 启动 SpacetimeDB 2.8.3 并发布隔离数据库,再通过 `npm run dev:api-server` 启动同一目标的 API 和 worker;`/v1/ping``/healthz``/readyz` 均返回 200 |
| MCP 真实 HTTP 链路 | 未认证返回 401;使用隔离数据库中的临时测试 API Keyinitialize 成功,tools/list 返回 44 个工具且包含全部 29 个旧工具,resources/list 返回原有 7 个资源 |
| 本地数据库读写 | 新工具创建/列表/重命名/读取/删除成功;同键重复创建返回同一项目;旧工具读写与新工具互通;重复语义读取保留完整项目;ownerUserId 额外字段被拒绝且未产生写入 |
运行核验使用独立测试身份和数据库,不经过真实用户登录或外部账号开通流程;测试项目已清理,临时 API Key 已撤销。最初启动未先确认数据库可用,导致连接失败;补齐数据库启动和发布后,实际 HTTP smoke 通过,无需修改业务代码。
未验证:真实用户登录/发放 API Key 全流程、付费 Provider 生成、外部 MCP 客户端对 oneOf 参数的展示与使用、远端部署。本地验收不代表这些项目已通过。
## 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):去背景模式与背景色已有合同。
当前参数事实按 2026-09-23 的仓库内容核对;后续实现时再次以代码与 OpenAPI 为准,发现变动同步修订本文,不维护第二份脱离 API 的字段真相。
@@ -78,6 +78,8 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原
## 托管远程 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` 作为业务身份。
MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarrative.world` / `genarrative.world`、开发入口 `dev.genarrative.world` 和本机开发入口;对应 HTTPS Origin 也必须与公开环境同步登记。新增公开环境域名时,必须在发布前使用该域名的真实 `Host``Origin` 执行 `initialize` 回归,不能只用 `localhost` 单测证明端点可用。
@@ -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/URLvariation 参考生成新版本,使用 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/gridY132),不传 sliceCountconnected-components 可传 sliceCount1256),不传 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 用于短声音、环境声和交互反馈,必填 promptduration 省略/null 自动,手动 0.530 秒;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/collapsedcreate_asset 登记已有媒体元数据,不上传或生成,不支持幂等键;update_asset 修改名称或通过 folderId 移动素材。已经入库的生成结果不要重复登记。删除用 delete_resources。"
},
"check_generation": {
"title": "查看生成进度与结果",
"description": "按 operationId 查询一次生成任务状态,不阻塞等待完成。queued/running 按 pollAfterMs 再查;completed 才消费 result,保留 warning、sliceWarningfailed 如实返回安全错误。跨账号与不存在任务同样不可见。已知任务 ID 时直接查询;提交响应丢失而没有 ID 时,用原 API、原请求和原 idempotencyKey 重试提交取得受理结果,不换键重提。查询超时不改变任务状态。"
},
"delete_resources": {
"title": "删除资源",
"description": "按精确 ID 删除,调用前明确目标范围并取得相应用户授权。action=delete_project 删除项目并级联清理默认画布和项目资源元数据;delete_folder 将其素材移到默认文件夹后删除文件夹,默认文件夹不可删除;delete_asset 删除素材记录并处理关联精选审核状态。文件夹/素材记录删除不等于删除 OSS 文件。不支持批量、模糊匹配或按名称删除。"
}
}
+347 -37
View File
@@ -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<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
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<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
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<String, Value>,
context: &McpRequestContext<RoleServer>,
optional_idempotency_key: Option<axum::http::HeaderValue>,
) -> Result<Value, Value> {
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::<Value>(&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<String, Value>,
authorization: axum::http::HeaderValue,
request_context: RequestContext,
optional_idempotency_key: Option<axum::http::HeaderValue>,
) -> Result<Request<Body>, 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::<Value>(&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::<Vec<_>>();
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<Body> {
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::<RequestContext>()
.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::<Value>(&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<ExternalApiKeyRecord, SpacetimeClientError>> {
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]
@@ -0,0 +1,431 @@
//! 语义入口只负责操作选择和参数位置转换,业务校验与副作用仍由 External router 承担。
use super::*;
use axum::http::HeaderValue;
pub(super) static TOOLS: LazyLock<Vec<SemanticTool>> = LazyLock::new(build_tools);
pub(super) struct SemanticTool {
pub(super) tool: Tool,
actions: Vec<Action>,
}
struct Action {
name: Option<&'static str>,
operation: &'static McpOperation,
input_schema: Value,
key_schema: Option<Value>,
fixed_body: Map<String, Value>,
}
pub(super) struct PreparedCall {
pub(super) operation: &'static McpOperation,
pub(super) arguments: Map<String, Value>,
pub(super) optional_idempotency_key: Option<HeaderValue>,
}
pub(super) fn find(name: &str) -> Option<&'static SemanticTool> {
TOOLS.iter().find(|entry| entry.tool.name == name)
}
fn build_tools() -> Vec<SemanticTool> {
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::<Vec<_>>();
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::<Vec<_>>()},
"input": {"type": "object"}
},
"required": ["action", "input"],
"additionalProperties": false,
"oneOf": actions.iter().map(Action::call_schema).collect::<Vec<_>>()
});
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<String, Value>) -> Result<PreparedCall, Value> {
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 必须为 1128 个非空白 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::<Map<_, _>>();
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<String, Value>) -> 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;
File diff suppressed because it is too large Load Diff