修正MCP语义工具的破坏性风险注解
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m27s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m0s
Project CI / Backend tests (pull_request) Successful in 3m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m4s
Project CI / Native shell tests (pull_request) Successful in 6m4s
Project CI / Frontend tests (pull_request) Successful in 2m16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m10s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m52s
Project CI / Repository checks (pull_request) Successful in 2m11s

按 API 操作显式声明风险并汇总到工具级注解
修正上传确认可覆盖已有对象元数据的漏标
增加工具风险注解回归测试并同步工程文档
This commit is contained in:
2026-09-24 08:43:31 +00:00
parent 53cf0304ae
commit e4adde0573
4 changed files with 85 additions and 11 deletions
@@ -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
@@ -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、instructionsresources 更新的分支上运行 `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、instructionsresources 和风险注解修复的分支上运行 `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 Keyinitialize 成功,tools/list 返回 44 个工具且包含全部 29 个旧工具,resources/list 返回原有 7 个资源 |
@@ -16,6 +16,7 @@ struct Action {
input_schema: Value,
key_schema: Option<Value>,
fixed_body: Map<String, Value>,
destructive: bool,
}
pub(super) struct PreparedCall {
@@ -135,14 +136,7 @@ fn build_tools() -> Vec<SemanticTool> {
.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 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<SemanticTool> {
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,
}
}
@@ -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)