diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index b386cf1d7..2e78e469d 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):15 个语义工具与全部旧工具并存,复用现有 API 分派和 schema;多功能入口使用 action/input,结果不裁剪,可选幂等只扩展新入口。instructions 与 resources 已同步当前工具选择、调用流程及结果说明;资源 URI 保留,Skill 文档源与下载包共用,线上状态按实际部署核对。 +2. [外部 MCP 语义工具说明与参数设计](../../technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个语义工具与全部旧工具并存,复用现有 API 分派和 schema;多功能入口使用 action/input,结果不裁剪,可选幂等只扩展新入口。语义工具按 operation 显式声明 destructive 风险,并汇总各 action;上传确认包含已有元数据更新风险。instructions 与 resources 已同步当前工具选择、调用流程及结果说明;资源 URI 保留,Skill 文档源与下载包共用,线上状态按实际部署核对。 AI 游戏创作 / DirectProject / UI workflow: diff --git a/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md b/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md index f066debbf..b4aa7d5c1 100644 --- a/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md +++ b/docs/technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md @@ -321,6 +321,7 @@ register_resource 只登记资源,并不自动创建画布图层。正常生 - variation 不暴露 kind,固定注入 `quick-edit`;对象确认不暴露或接受 ownerUserId。其余公开字段全部保留。稳定 key、原 API 与规范请求保持一致,重复语义入口不新增幂等命名空间。 - 项目列表固定 summary。get_project_resources 返回完整项目响应,不裁剪;下载地址保留 objectKey、legacyPublicPath 与 expireSeconds。所有结果复用现有成功解包与结构化错误处理,不裁剪告警、revision 冲突或异步结果。 - 读取工具标记 readOnly;含删除、覆盖或移动已有状态的工具标记 destructive;生成类说明付费及异步语义,可能替换已有图层的生成工具也按潜在破坏性标记。openWorld 仅用于会访问外部服务的能力,不作为付费标志。注解不替代用户授权或后端校验。 +- 语义工具的 destructive 按 API operation 显式声明,并取各 action 风险的并集,不根据 HTTP 方法或参数字段名推断。`prepare_asset_upload/confirm_upload` 可以更新同一 owner 的已有对象元数据,因此整个上传工具标记 destructive;申请上传票据本身仍属于新增操作。新增 operation 必须补充风险声明,原有工具的注解保持兼容。 ### 5.3 验收 @@ -332,8 +333,8 @@ register_resource 只登记资源,并不自动创建画布图层。正常生 | 验证范围 | 证据与结果 | | --- | --- | -| 工具与参数合同 | `cargo test --locked -p api-server external_mcp`:24 项通过,覆盖 44 个工具、31 个 action、旧定义保留、schema 展开、参数映射、错分支拒绝、必填/可选幂等和等价入口 | -| 认证与既有 API 回归 | 2026-09-24 在包含最新 master、instructions 和 resources 更新的分支上运行 `cargo test --locked -p api-server external_`:155 项通过,含 MCP 内外认证、scope 拒绝、跨 owner 隔离、异步及 OpenAPI 回归 | +| 工具与参数合同 | `external_` 回归中的 25 项 `external_mcp` 测试通过,覆盖 44 个工具、31 个 action、旧定义保留、schema 展开、参数映射、错分支拒绝、必填/可选幂等、等价入口及 15 个语义工具的风险注解 | +| 认证与既有 API 回归 | 2026-09-24 在包含最新 master、instructions、resources 和风险注解修复的分支上运行 `cargo test --locked -p api-server external_`:156 项通过,含 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 Key,initialize 成功,tools/list 返回 44 个工具且包含全部 29 个旧工具,resources/list 返回原有 7 个资源 | diff --git a/server-rs/crates/api-server/src/external_mcp/semantic.rs b/server-rs/crates/api-server/src/external_mcp/semantic.rs index 73bbc65d4..04a25aabc 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic.rs @@ -16,6 +16,7 @@ struct Action { input_schema: Value, key_schema: Option, fixed_body: Map, + destructive: bool, } pub(super) struct PreparedCall { @@ -135,14 +136,7 @@ fn build_tools() -> Vec { .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 destructive = actions.iter().any(|a| a.destructive); let schema = tool_schema(&actions); let mut tool = Tool::new( name.to_string(), @@ -176,6 +170,40 @@ fn build_tools() -> Vec { impl Action { fn new(name: Option<&'static str>, operation_id: &str, openapi: &Value) -> Self { + // 按实际副作用声明;POST 也可能覆盖已有记录,参数名不能代表风险。 + let destructive = match operation_id { + "listEditorProjects" + | "loadRecentEditorProject" + | "getEditorProject" + | "getEditorAssetLibrary" + | "getExternalAssetReadUrl" + | "getExternalEditorGenerationJob" + | "createEditorProject" + | "createEditorProjectResource" + | "createEditorAssetFolder" + | "createEditorAsset" + | "createExternalDirectUploadTicket" => false, + // 对象确认允许更新同一 owner 的已有对象元数据。 + "confirmExternalAssetObject" + | "renameEditorProject" + | "saveEditorProjectCanvas" + | "updateEditorAssetFolder" + | "updateEditorAsset" + | "deleteEditorProject" + | "deleteEditorAssetFolder" + | "deleteEditorAsset" => true, + // 生成完成可修改已有画布状态,编辑与抠图还支持原位替换。 + "generateExternalEditorImage" + | "editExternalEditorImage" + | "removeExternalEditorImageBackground" + | "generateExternalEditorIconSpritesheet" + | "extractExternalEditorUiDesignAssets" + | "generateExternalEditorCharacterAnimation" + | "generateExternalEditorVideo" + | "generateExternalEditorSoundEffect" + | "generateExternalEditorBackgroundMusic" => true, + _ => panic!("semantic operation must declare destructive risk: {operation_id}"), + }; let operation = MCP_OPERATIONS .iter() .find(|op| op.operation_id == operation_id) @@ -259,6 +287,7 @@ impl Action { input_schema, key_schema, fixed_body, + destructive, } } 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 index 725629993..9d66978b6 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -55,6 +55,50 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { assert_eq!(names.len(), 44); } +#[test] +fn semantic_annotations_cover_existing_state_changes_in_any_action() { + for (name, destructive) in [ + ("find_canvas_projects", false), + ("find_assets", false), + ("check_generation", false), + ("manage_canvas_projects", true), + ("prepare_asset_upload", true), + ("generate_image", true), + ("modify_image", true), + ("generate_icon_spritesheet", true), + ("extract_ui_assets", true), + ("generate_character_animation", true), + ("generate_video", true), + ("generate_audio", true), + ("edit_canvas", true), + ("organize_asset_library", true), + ("delete_resources", true), + ] { + let tool = &find(name).expect("semantic tool must exist").tool; + let serialized = serde_json::to_value(tool).unwrap(); + assert_eq!( + serialized["annotations"]["destructiveHint"], + json!(destructive), + "incorrect published risk for {name}" + ); + } + + // 申请票据只新增;确认对象会 upsert,整个工具必须涵盖该分支的风险。 + let upload = find("prepare_asset_upload").unwrap(); + let ticket = upload + .actions + .iter() + .find(|a| a.name == Some("create_upload_ticket")) + .unwrap(); + let confirm = upload + .actions + .iter() + .find(|a| a.name == Some("confirm_upload")) + .unwrap(); + assert!(!ticket.destructive); + assert!(confirm.destructive); +} + #[test] fn every_semantic_action_maps_to_one_existing_operation_and_correct_parameter_location() { // (tool, action, operationId, input, pathParameters, queryParameters, body)