升级mcp,增加按语义分类的工具。旧工具不变 (#493)
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / Backend tests (push) Successful in 3m45s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m30s
Project CI / Frontend tests (push) Successful in 1m53s
Project CI / Native shell tests (push) Successful in 5m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m15s
Project CI / Repository checks (push) Successful in 1m58s
Project CI / AI game creator shell web tests (push) Successful in 1m27s

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/493
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #493.
This commit is contained in:
2026-09-24 17:19:45 +08:00
committed by 孔令弘
parent ec3a187dd7
commit a07bff85ec
13 changed files with 2147 additions and 208 deletions
+356 -38
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 =
@@ -54,7 +56,15 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str =
"genarrative://external-editor/skill/references/requests-and-outputs.md";
const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
const MCP_INSTRUCTIONS: &str = r#"陶泥儿提供画布项目管理、素材库管理,以及图片、角色动画、视频和音频生成能力。
按用户任务需要创建或复用项目、素材文件夹,不默认创建。生成结果需要进入画布或素材库时,使用对应生成工具支持的目标字段;已有落库结果不要重复登记。
生成操作会产生费用,采用异步提交。每次独立生成使用稳定的 idempotencyKey;取得任务 ID 后,使用 check_generation 按 pollAfterMs 查询,直到 completed 或 failed。查询超时不代表生成失败,不要因此重新提交或更换幂等键。
本地参考文件通过 prepare_asset_upload 获取上传票据,由调用方实际上传后确认对象。后续引用遵循各工具要求,使用稳定的对象键或资源、素材 ID;不要把临时下载 URL 当作持久引用。
以实际返回的结果和告警判断完成情况,部分产物成功不代表所有处理步骤成功。具体参数以工具 schema 和说明为准;需要详细流程、示例或 API 契约时,通过 resources/list 查找相关文档。"#;
#[derive(Clone, Debug)]
struct McpOperation {
@@ -125,7 +135,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 +148,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 +156,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 +508,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 +532,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 +611,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 +1031,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 +1079,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,460 @@
//! 语义入口只负责操作选择和参数位置转换,业务校验与副作用仍由 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>,
destructive: bool,
}
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.destructive);
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 {
// 按实际副作用声明;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)
.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,
destructive,
}
}
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