新增外部MCP语义工具并保留原有入口
新增十五个语义工具及中文说明和分支参数契约 复用现有API分派并支持新入口的可选创建幂等键 保留全部原工具和API并补充分派与兼容回归测试 同步工程文档及验收记录并注明本地数据库阻断的运行验证
This commit is contained in:
@@ -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 文件。不支持批量、模糊匹配或按名称删除。"
|
||||
}
|
||||
}
|
||||
@@ -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 必须为 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::<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
Reference in New Issue
Block a user