use std::sync::{Arc, LazyLock}; use axum::{ body::Body, http::{ HeaderMap, Method, Request, header::{AUTHORIZATION, CONTENT_TYPE}, }, }; use http_body_util::BodyExt; use rmcp::{ RoleServer, ServerHandler, model::{ CallToolRequestParams, CallToolResult, ErrorData, Implementation, ListResourcesResult, ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, }, service::RequestContext as McpRequestContext, transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }, }; use serde_json::{Map, Value, json}; use tower::ServiceExt; use crate::{modules, request_context::RequestContext, state::AppState}; const OPENAPI_JSON: &str = include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); const SKILL_MD: &str = include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"); const SKILL_CAPABILITY_ROUTING_MD: &str = include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md" ); const SKILL_API_OPERATIONS_MD: &str = include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md" ); const SKILL_AUTHENTICATION_AND_SAFETY_MD: &str = include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md" ); const SKILL_REQUESTS_AND_OUTPUTS_MD: &str = include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md" ); const USAGE_URI: &str = "genarrative://external-editor/usage"; const OPENAPI_URI: &str = "genarrative://external-editor/openapi"; const SKILL_URI: &str = "genarrative://external-editor/skill"; const SKILL_CAPABILITY_ROUTING_URI: &str = "genarrative://external-editor/skill/references/capability-routing.md"; const SKILL_API_OPERATIONS_URI: &str = "genarrative://external-editor/skill/references/api-operations.md"; const SKILL_AUTHENTICATION_AND_SAFETY_URI: &str = "genarrative://external-editor/skill/references/authentication-and-safety.md"; 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;查询超时不能重新提交。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; #[derive(Clone, Debug)] struct McpOperation { tool_name: String, operation_id: String, method: Method, path_template: String, description: String, input_schema: Arc>, requires_idempotency_key: bool, } static MCP_OPERATIONS: LazyLock> = LazyLock::new(build_mcp_operations); #[derive(Clone, Debug, Default)] pub(crate) struct GenarrativeExternalMcp; pub(crate) type GenarrativeExternalMcpService = StreamableHttpService; pub(crate) fn service() -> GenarrativeExternalMcpService { let config = StreamableHttpServerConfig::default() .with_stateful_mode(false) .with_json_response(true) .with_sse_keep_alive(None) .with_allowed_hosts([ "www.genarrative.world", "genarrative.world", "dev.genarrative.world", "localhost", "127.0.0.1", "::1", ]) .with_allowed_origins([ "https://www.genarrative.world", "https://genarrative.world", "https://dev.genarrative.world", "http://localhost:3000", "http://127.0.0.1:3000", ]); StreamableHttpService::new( || Ok(GenarrativeExternalMcp), Arc::new(LocalSessionManager::default()), config, ) } impl ServerHandler for GenarrativeExternalMcp { fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_resources() .build(), ) .with_server_info( Implementation::new("genarrative-external-editor", env!("CARGO_PKG_VERSION")) .with_title("陶泥儿外部编辑器") .with_description("通过托管式 MCP 使用陶泥儿画布、素材库和异步生成 API") .with_website_url("https://www.genarrative.world"), ) .with_instructions(MCP_INSTRUCTIONS) } async fn list_tools( &self, _request: Option, _context: McpRequestContext, ) -> Result { Ok(ListToolsResult::with_all_items( MCP_OPERATIONS.iter().map(mcp_operation_tool).collect(), )) } fn get_tool(&self, name: &str) -> Option { MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == name) .map(mcp_operation_tool) } async fn call_tool( &self, request: CallToolRequestParams, context: McpRequestContext, ) -> Result { 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 { Ok(value) => Ok(CallToolResult::structured(value)), Err(value) => Ok(CallToolResult::structured_error(value)), } } async fn list_resources( &self, _request: Option, _context: McpRequestContext, ) -> Result { Ok(ListResourcesResult::with_all_items(mcp_resources())) } async fn read_resource( &self, request: ReadResourceRequestParams, _context: McpRequestContext, ) -> Result { let (text, mime_type) = mcp_resource_contents(request.uri.as_str()) .ok_or_else(|| ErrorData::resource_not_found("资源不存在", None))?; Ok(ReadResourceResult::new(vec![ ResourceContents::text(text, request.uri).with_mime_type(mime_type), ])) } } fn mcp_resources() -> Vec { vec![ Resource::new(USAGE_URI, "usage") .with_title("陶泥儿外部编辑器使用说明") .with_description("画布、素材、上传、异步生成和告警处理工作流") .with_mime_type("text/markdown"), Resource::new(OPENAPI_URI, "openapi") .with_title("陶泥儿外部编辑器 OpenAPI") .with_description("MCP 工具所映射的完整 REST 契约") .with_mime_type("application/json"), Resource::new(SKILL_URI, "skill") .with_title("陶泥儿外部编辑器 Skill") .with_description("外部编辑器 Skill 主入口;细节按 references 渐进读取") .with_mime_type("text/markdown"), Resource::new(SKILL_CAPABILITY_ROUTING_URI, "skill-capability-routing") .with_title("陶泥儿外部编辑器能力路由") .with_description("按用户意图选择 MCP tool 或 External v1 API") .with_mime_type("text/markdown"), Resource::new(SKILL_API_OPERATIONS_URI, "skill-api-operations") .with_title("陶泥儿外部编辑器 API 操作") .with_description("项目、素材、上传、异步生成和任务查询操作表") .with_mime_type("text/markdown"), Resource::new( SKILL_AUTHENTICATION_AND_SAFETY_URI, "skill-authentication-and-safety", ) .with_title("陶泥儿外部编辑器认证与安全") .with_description("API Key、幂等、重试、本地文件和安全边界") .with_mime_type("text/markdown"), Resource::new(SKILL_REQUESTS_AND_OUTPUTS_URI, "skill-requests-and-outputs") .with_title("陶泥儿外部编辑器请求与输出") .with_description("请求构造、异步轮询、完成结果和告警处理") .with_mime_type("text/markdown"), ] } fn mcp_resource_contents(uri: &str) -> Option<(&'static str, &'static str)> { match uri { USAGE_URI => Some((MCP_INSTRUCTIONS, "text/markdown")), OPENAPI_URI => Some((OPENAPI_JSON, "application/json")), SKILL_URI => Some((SKILL_MD, "text/markdown")), SKILL_CAPABILITY_ROUTING_URI => Some((SKILL_CAPABILITY_ROUTING_MD, "text/markdown")), SKILL_API_OPERATIONS_URI => Some((SKILL_API_OPERATIONS_MD, "text/markdown")), SKILL_AUTHENTICATION_AND_SAFETY_URI => { Some((SKILL_AUTHENTICATION_AND_SAFETY_MD, "text/markdown")) } SKILL_REQUESTS_AND_OUTPUTS_URI => Some((SKILL_REQUESTS_AND_OUTPUTS_MD, "text/markdown")), _ => None, } } fn build_mcp_operations() -> Vec { let openapi: Value = serde_json::from_str(OPENAPI_JSON).expect("embedded OpenAPI must parse"); let mut operations = Vec::new(); let Some(paths) = openapi.get("paths").and_then(Value::as_object) else { return operations; }; for (path, path_item) in paths { let Some(path_item) = path_item.as_object() else { continue; }; for method_name in ["get", "post", "patch", "put", "delete"] { let Some(operation) = path_item.get(method_name).and_then(Value::as_object) else { continue; }; if operation.get("x-mcp-excluded").and_then(Value::as_bool) == Some(true) { continue; } let Some(operation_id) = operation.get("operationId").and_then(Value::as_str) else { continue; }; let method = Method::from_bytes(method_name.to_ascii_uppercase().as_bytes()) .expect("known HTTP method"); let requires_idempotency_key = operation_requires_idempotency_key(&openapi, path_item, operation); let description = operation .get("description") .or_else(|| operation.get("summary")) .and_then(Value::as_str) .unwrap_or("调用陶泥儿外部编辑器 API"); let path_template = if operation_id == "listEditorProjects" { format!("{path}?view=summary") } else { path.clone() }; operations.push(McpOperation { tool_name: camel_to_snake(operation_id), operation_id: operation_id.to_string(), method, path_template, description: format!("{description}({} {path})", method_name.to_uppercase()), input_schema: Arc::new(build_operation_input_schema( &openapi, path_item, operation, requires_idempotency_key, )), requires_idempotency_key, }); } } operations.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); operations } fn operation_requires_idempotency_key( openapi: &Value, path_item: &Map, operation: &Map, ) -> bool { path_item .get("parameters") .and_then(Value::as_array) .into_iter() .flatten() .chain( operation .get("parameters") .and_then(Value::as_array) .into_iter() .flatten(), ) .filter_map(|parameter| resolve_openapi_reference(openapi, parameter)) .any(|parameter| { parameter.get("in").and_then(Value::as_str) == Some("header") && parameter .get("name") .and_then(Value::as_str) .is_some_and(|name| name.eq_ignore_ascii_case("Idempotency-Key")) && parameter.get("required").and_then(Value::as_bool) == Some(true) }) } fn build_operation_input_schema( openapi: &Value, path_item: &Map, operation: &Map, requires_idempotency_key: bool, ) -> Map { let mut properties = Map::new(); let fixes_project_list_to_summary = operation.get("operationId").and_then(Value::as_str) == Some("listEditorProjects"); let parameters = path_item .get("parameters") .and_then(Value::as_array) .into_iter() .flatten() .chain( operation .get("parameters") .and_then(Value::as_array) .into_iter() .flatten(), ) .filter_map(|parameter| resolve_openapi_reference(openapi, parameter)) .collect::>(); let mut top_level_required = Vec::new(); for location in ["path", "query"] { let mut parameter_properties = Map::new(); let mut required = Vec::new(); for parameter in ¶meters { if parameter.get("in").and_then(Value::as_str) != Some(location) { continue; } let Some(name) = parameter.get("name").and_then(Value::as_str) else { continue; }; if fixes_project_list_to_summary && location == "query" && name == "view" { continue; } parameter_properties.insert( name.to_string(), parameter .get("schema") .cloned() .unwrap_or_else(|| json!({})), ); if parameter.get("required").and_then(Value::as_bool) == Some(true) { required.push(Value::String(name.to_string())); } } if !parameter_properties.is_empty() { let mut schema = json!({ "type": "object", "properties": parameter_properties, "additionalProperties": false, }); if !required.is_empty() { schema["required"] = Value::Array(required); top_level_required.push(Value::String(format!("{location}Parameters"))); } properties.insert(format!("{location}Parameters"), schema); } } if let Some(request_body) = operation .get("requestBody") .and_then(|value| resolve_openapi_reference(openapi, value)) { let body_schema = request_body .get("content") .and_then(|content| content.get("application/json")) .and_then(|media_type| media_type.get("schema")) .map(|schema| inline_openapi_schema(openapi, schema, 0)) .unwrap_or_else(|| { json!({ "type": "object", "description": "请求体。精确字段、枚举和约束见 genarrative://external-editor/openapi。", "additionalProperties": true, }) }); properties.insert("body".to_string(), body_schema); if request_body.get("required").and_then(Value::as_bool) == Some(true) { top_level_required.push(json!("body")); } } if requires_idempotency_key { properties.insert( "idempotencyKey".to_string(), json!({ "type": "string", "minLength": 1, "maxLength": 128, "description": "本次逻辑生成请求的稳定幂等键;结果不确定时必须复用原值。" }), ); top_level_required.push(json!("idempotencyKey")); } let mut schema = Map::from_iter([ ("type".to_string(), json!("object")), ("properties".to_string(), Value::Object(properties)), ("additionalProperties".to_string(), json!(false)), ]); if !top_level_required.is_empty() { top_level_required.sort_by(|left, right| left.as_str().cmp(&right.as_str())); top_level_required.dedup(); schema.insert("required".to_string(), Value::Array(top_level_required)); } schema } fn resolve_openapi_reference<'a>(openapi: &'a Value, value: &'a Value) -> Option<&'a Value> { let Some(reference) = value.get("$ref").and_then(Value::as_str) else { return Some(value); }; let pointer = reference.strip_prefix('#')?; openapi.pointer(pointer) } fn inline_openapi_schema(openapi: &Value, schema: &Value, depth: usize) -> Value { if depth >= 32 { return json!({"type": "object"}); } if let Some(reference) = schema.get("$ref").and_then(Value::as_str) && let Some(pointer) = reference.strip_prefix('#') && let Some(resolved) = openapi.pointer(pointer) { return inline_openapi_schema(openapi, resolved, depth + 1); } match schema { Value::Array(values) => Value::Array( values .iter() .map(|value| inline_openapi_schema(openapi, value, depth + 1)) .collect(), ), Value::Object(values) => Value::Object( values .iter() .map(|(key, value)| { ( key.clone(), inline_openapi_schema(openapi, value, depth + 1), ) }) .collect(), ), value => value.clone(), } } fn mcp_operation_tool(operation: &McpOperation) -> Tool { let read_only = operation.method == Method::GET; let destructive = operation.method == Method::DELETE || operation.requires_idempotency_key; let annotations = ToolAnnotations::new() .read_only(read_only) .destructive(destructive) .idempotent(read_only || operation.requires_idempotency_key) .open_world(operation.requires_idempotency_key); let mut tool = Tool::new( operation.tool_name.clone(), operation.description.clone(), operation.input_schema.clone(), ); tool.title = Some(operation.operation_id.clone()); tool.annotations = Some(annotations); tool } async fn dispatch_operation( operation: &McpOperation, arguments: Map, context: &McpRequestContext, ) -> Result { validate_required_body(operation, &arguments)?; let parts = context .extensions .get::() .ok_or_else(|| json!({"error": "MCP HTTP 请求上下文缺失"}))?; let state = parts .extensions .get::() .cloned() .ok_or_else(|| json!({"error": "MCP 应用状态缺失"}))?; let request_context = parts .extensions .get::() .cloned() .ok_or_else(|| json!({"error": "MCP request_id 上下文缺失"}))?; let authorization = parts .headers .get(AUTHORIZATION) .cloned() .ok_or_else(|| json!({"error": "Authorization 请求头缺失"}))?; 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 { let value = json_scalar_string(value) .ok_or_else(|| json!({"error": format!("路径参数 {name} 必须是标量")}))?; path = path.replace(&format!("{{{name}}}"), urlencoding::encode(&value).as_ref()); } } if path.contains('{') { return Err(json!({"error": "缺少必填路径参数"})); } if let Some(query) = arguments.get("queryParameters").and_then(Value::as_object) { append_operation_query_parameters(operation, &mut path, query); } let body = arguments.get("body").cloned().unwrap_or(Value::Null); let body = if body.is_null() { Body::empty() } else { Body::from(body.to_string()) }; let mut request = Request::builder() .method(operation.method.clone()) .uri(path) .header(AUTHORIZATION, authorization) .body(body) .map_err(|_| json!({"error": "无法构造内部 API 请求"}))?; request.extensions_mut().insert(request_context); if arguments.get("body").is_some() { request.headers_mut().insert( CONTENT_TYPE, "application/json".parse().expect("valid content type"), ); } apply_operation_headers(operation, &arguments, request.headers_mut())?; 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::(&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 apply_operation_headers( operation: &McpOperation, arguments: &Map, headers: &mut HeaderMap, ) -> Result<(), Value> { if !operation.requires_idempotency_key { return Ok(()); } let idempotency_key = arguments .get("idempotencyKey") .and_then(Value::as_str) .ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?; headers.insert( "idempotency-key", idempotency_key .parse() .map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?, ); Ok(()) } fn validate_required_body( operation: &McpOperation, arguments: &Map, ) -> Result<(), Value> { let body_is_required = operation .input_schema .get("required") .and_then(Value::as_array) .is_some_and(|required| required.iter().any(|name| name.as_str() == Some("body"))); if !body_is_required { return Ok(()); } let required_fields = operation.input_schema["properties"]["body"] .get("required") .and_then(Value::as_array) .cloned() .unwrap_or_default(); let Some(body) = arguments.get("body").filter(|body| !body.is_null()) else { return Err(json!({ "error": "缺少请求体", "requiredFields": required_fields, })); }; let Some(body) = body.as_object() else { return Err(json!({ "error": "请求体必须是 JSON 对象", "expectedType": "object", })); }; let missing_fields = required_fields .iter() .filter(|field| { field .as_str() .is_some_and(|field| !body.contains_key(field)) }) .cloned() .collect::>(); if missing_fields.is_empty() { Ok(()) } else { Err(json!({ "error": "缺少必填字段", "missingFields": missing_fields, })) } } fn append_query_parameters(path: &mut String, query: &Map) { let mut serializer = url::form_urlencoded::Serializer::new(String::new()); for (name, value) in query { match value { Value::Array(values) => { for value in values { if let Some(value) = json_scalar_string(value) { serializer.append_pair(name, &value); } } } value => { if let Some(value) = json_scalar_string(value) { serializer.append_pair(name, &value); } } } } let query = serializer.finish(); if !query.is_empty() { path.push(if path.contains('?') { '&' } else { '?' }); path.push_str(&query); } } fn append_operation_query_parameters( operation: &McpOperation, path: &mut String, query: &Map, ) { if operation.operation_id == "listEditorProjects" { let mut query = query.clone(); query.remove("view"); append_query_parameters(path, &query); } else { append_query_parameters(path, query); } } fn unwrap_external_api_success_payload(payload: Value) -> Value { payload .get("data") .filter(|_| payload.get("ok").and_then(Value::as_bool) == Some(true)) .cloned() .unwrap_or(payload) } fn json_scalar_string(value: &Value) -> Option { match value { Value::String(value) => Some(value.clone()), Value::Number(value) => Some(value.to_string()), Value::Bool(value) => Some(value.to_string()), Value::Null | Value::Array(_) | Value::Object(_) => None, } } fn camel_to_snake(value: &str) -> String { let mut output = String::with_capacity(value.len() + 8); for (index, character) in value.chars().enumerate() { if character.is_ascii_uppercase() { if index > 0 { output.push('_'); } output.push(character.to_ascii_lowercase()); } else { output.push(character); } } output } #[cfg(test)] mod tests { use super::*; use crate::{config::AppConfig, request_context::attach_request_context}; use axum::{ http::{ StatusCode, header::{ACCEPT, HOST, ORIGIN}, }, middleware, }; #[test] fn mcp_resources_expose_complete_progressive_skill_documents() { let resources = serde_json::to_string(&mcp_resources()).expect("resources should serialize"); let expected = [ (USAGE_URI, MCP_INSTRUCTIONS, "text/markdown"), (OPENAPI_URI, OPENAPI_JSON, "application/json"), (SKILL_URI, SKILL_MD, "text/markdown"), ( SKILL_CAPABILITY_ROUTING_URI, SKILL_CAPABILITY_ROUTING_MD, "text/markdown", ), ( SKILL_API_OPERATIONS_URI, SKILL_API_OPERATIONS_MD, "text/markdown", ), ( SKILL_AUTHENTICATION_AND_SAFETY_URI, SKILL_AUTHENTICATION_AND_SAFETY_MD, "text/markdown", ), ( SKILL_REQUESTS_AND_OUTPUTS_URI, SKILL_REQUESTS_AND_OUTPUTS_MD, "text/markdown", ), ]; assert_eq!(mcp_resources().len(), expected.len()); for (uri, contents, mime_type) in expected { assert!(resources.contains(uri), "missing MCP resource {uri}"); assert_eq!(mcp_resource_contents(uri), Some((contents, mime_type))); } } #[test] fn openapi_operations_become_unique_mcp_tools() { let names: std::collections::BTreeMap<_, _> = MCP_OPERATIONS .iter() .map(|operation| { ( operation.tool_name.as_str(), operation.path_template.as_str(), ) }) .collect(); assert_eq!(names.len(), MCP_OPERATIONS.len()); assert!(names.contains_key("generate_external_editor_image")); assert!(names.contains_key("remove_external_editor_image_background")); assert!(names.contains_key("get_external_editor_generation_job")); let list_projects = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == "list_editor_projects") .expect("project list tool should exist"); assert_eq!(list_projects.method, Method::GET); let create_project = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == "create_editor_project") .expect("project create tool should exist"); assert_eq!(create_project.method, Method::POST); } #[test] fn project_list_tool_always_requests_summary_view() { let operation = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == "list_editor_projects") .expect("project list tool should exist"); assert_eq!( operation.path_template, "/api/external/v1/editor/projects?view=summary" ); assert!( operation.input_schema["properties"] .get("queryParameters") .is_none(), "MCP 项目列表固定 summary 后不得再向 Agent 暴露 REST view 参数" ); let mut path = operation.path_template.clone(); append_operation_query_parameters( operation, &mut path, &Map::from_iter([ ("limit".to_string(), json!(20)), ("view".to_string(), json!("full")), ]), ); assert_eq!( path, "/api/external/v1/editor/projects?view=summary&limit=20" ); let mut path = "/api/external/v1/editor/assets".to_string(); append_query_parameters( &mut path, &Map::from_iter([("folderId".to_string(), json!("folder-1"))]), ); assert_eq!(path, "/api/external/v1/editor/assets?folderId=folder-1"); } #[test] fn required_request_body_errors_are_derived_from_tool_schema() { for (tool_name, required_fields) in [ ( "confirm_external_asset_object", vec![json!("objectKey"), json!("assetKind")], ), ( "create_editor_asset", vec![ json!("folderId"), json!("label"), json!("imageSrc"), json!("width"), json!("height"), json!("sourceType"), ], ), ("create_editor_asset_folder", vec![json!("label")]), ( "create_external_direct_upload_ticket", vec![json!("legacyPrefix"), json!("fileName")], ), ] { let operation = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == tool_name) .unwrap_or_else(|| panic!("{tool_name} should exist")); for arguments in [ Map::new(), Map::from_iter([("body".to_string(), Value::Null)]), ] { assert_eq!( validate_required_body(operation, &arguments), Err(json!({ "error": "缺少请求体", "requiredFields": required_fields, })), "{tool_name}" ); } assert_eq!( validate_required_body( operation, &Map::from_iter([("body".to_string(), json!({}))]), ), Err(json!({ "error": "缺少必填字段", "missingFields": required_fields, })), "{tool_name}" ); assert_eq!( validate_required_body( operation, &Map::from_iter([("body".to_string(), json!([]))]), ), Err(json!({ "error": "请求体必须是 JSON 对象", "expectedType": "object", })), "{tool_name}" ); } } #[test] fn optional_request_body_is_not_rejected() { let operation = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == "create_editor_project") .expect("project create tool should exist"); assert_eq!(validate_required_body(operation, &Map::new()), Ok(())); assert_eq!( validate_required_body( operation, &Map::from_iter([("body".to_string(), Value::Null)]), ), Ok(()) ); } #[test] fn generation_tools_require_idempotency_key() { for tool_name in [ "generate_external_editor_image", "remove_external_editor_image_background", ] { let operation = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == tool_name) .unwrap_or_else(|| panic!("{tool_name} should exist")); assert!(operation.requires_idempotency_key, "{tool_name}"); assert_eq!( operation.input_schema.get("required"), Some(&json!(["body", "idempotencyKey"])), "{tool_name}" ); } let image_generation = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == "generate_external_editor_image") .expect("image generation tool should exist"); assert_eq!( image_generation.input_schema["properties"]["body"]["properties"]["projectId"]["type"], json!(["string", "null"]) ); let background_removal = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == "remove_external_editor_image_background") .expect("background removal tool should exist"); assert_eq!( background_removal.input_schema["properties"]["body"]["required"], json!(["sourceImageSrc"]) ); let mut headers = HeaderMap::new(); apply_operation_headers( background_removal, &Map::from_iter([("idempotencyKey".to_string(), json!("issue-178-request"))]), &mut headers, ) .expect("background removal idempotency header should be forwarded"); assert_eq!( headers .get("idempotency-key") .and_then(|value| value.to_str().ok()), Some("issue-178-request") ); } #[test] fn referenced_path_parameters_are_exposed_to_agents() { let operation = MCP_OPERATIONS .iter() .find(|operation| operation.tool_name == "get_editor_project") .expect("project lookup tool should exist"); assert_eq!( operation.input_schema["required"], json!(["pathParameters"]) ); assert_eq!( operation.input_schema["properties"]["pathParameters"]["required"], json!(["projectId"]) ); } #[test] fn tool_catalog_has_self_contained_bounded_schemas() { let tools = MCP_OPERATIONS .iter() .map(mcp_operation_tool) .collect::>(); 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) .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"))); } } #[test] fn openapi_documents_mcp_authentication_guide() { let openapi: Value = serde_json::from_str(OPENAPI_JSON).expect("external OpenAPI should parse"); let unauthorized = &openapi["paths"]["/api/external/v1/mcp"]["post"]["responses"]["401"]; assert_eq!( unauthorized["headers"]["WWW-Authenticate"]["schema"]["const"], json!("Bearer realm=\"genarrative-external-editor\"") ); assert_eq!( unauthorized["content"]["application/json"]["schema"]["$ref"], json!("#/components/schemas/McpAuthenticationGuideResponse") ); let guide = &openapi["components"]["schemas"]["McpAuthenticationGuideResponse"]["properties"] ["error"]["properties"]["details"]["properties"]["guide"]; assert_eq!( guide["properties"]["reason"]["const"], json!("MCP_AUTHENTICATION_REQUIRED") ); assert_eq!( guide["properties"]["action"]["const"], json!("CONFIGURE_BEARER_API_KEY") ); } #[test] fn mcp_tools_return_business_data_without_rest_envelope() { assert_eq!( unwrap_external_api_success_payload(json!({ "ok": true, "data": {"operationId": "task-1", "status": "queued"}, "meta": {"requestId": "request-1"} })), json!({"operationId": "task-1", "status": "queued"}) ); } #[tokio::test] async fn streamable_http_accepts_dev_host_and_origin_without_weakening_guards() { for (host, origin, expected_status) in [ ("dev.genarrative.world", None, StatusCode::OK), ( "dev.genarrative.world", Some("https://dev.genarrative.world"), StatusCode::OK, ), ("untrusted.example", None, StatusCode::FORBIDDEN), ( "dev.genarrative.world", Some("https://untrusted.example"), StatusCode::FORBIDDEN, ), ] { let mut request = Request::builder() .method(Method::POST) .uri("/api/external/v1/mcp") .header(HOST, host) .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json, text/event-stream"); if let Some(origin) = origin { request = request.header(ORIGIN, origin); } let request = request .body(Body::from( r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"dev-host-test-agent","version":"1.0"}}}"#, )) .expect("development initialize request should build"); let response = service() .oneshot(request) .await .expect("MCP service should be infallible"); assert_eq!(response.status(), expected_status, "host={host}"); assert!(response.headers().get("mcp-session-id").is_none()); } } #[tokio::test] async fn streamable_http_initialize_is_stateless_json() { let request = 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") .body(Body::from( r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test-agent","version":"1.0"}}}"#, )) .expect("initialize request should build"); let response = service() .oneshot(request) .await .expect("MCP service should be infallible"); assert_eq!(response.status(), StatusCode::OK); assert!(response.headers().get("mcp-session-id").is_none()); assert_eq!( response .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()), Some("application/json") ); let payload: Value = serde_json::from_slice( &response .into_body() .collect() .await .expect("initialize response body should read") .to_bytes(), ) .expect("initialize response should be JSON"); assert_eq!(payload["id"], json!(1)); assert_eq!( payload["result"]["serverInfo"]["name"], json!("genarrative-external-editor") ); assert!(payload["result"]["capabilities"]["tools"].is_object()); assert!(payload["result"]["capabilities"]["resources"].is_object()); assert!( payload["result"]["instructions"] .as_str() .is_some_and(|value| value.contains("异步提交")) ); for (method, assertion) in [ ("tools/list", "generate_external_editor_image"), ("resources/list", USAGE_URI), ] { let request = 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": 2, "method": method}).to_string(), )) .expect("catalog request should build"); let response = service() .oneshot(request) .await .expect("MCP service should be infallible"); assert_eq!(response.status(), StatusCode::OK, "{method}"); let body = response .into_body() .collect() .await .expect("catalog response body should read") .to_bytes(); let payload: Value = serde_json::from_slice(&body).expect("catalog response should be JSON"); assert!( payload["result"].to_string().contains(assertion), "{method}" ); } for (id, uri, expected_text) in [ (3, OPENAPI_URI, "陶泥儿外部编辑器 OpenAPI"), ( 4, SKILL_REQUESTS_AND_OUTPUTS_URI, "All nine generation POST routes require", ), ] { let request = 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": id, "method": "resources/read", "params": {"uri": uri} }) .to_string(), )) .expect("resource read request should build"); let response = service() .oneshot(request) .await .expect("MCP service should be infallible"); assert_eq!(response.status(), StatusCode::OK); let payload: Value = serde_json::from_slice( &response .into_body() .collect() .await .expect("resource response body should read") .to_bytes(), ) .expect("resource response should be JSON"); assert!( payload["result"]["contents"][0]["text"] .as_str() .is_some_and(|value| value.contains(expected_text)), "{uri}" ); } } #[tokio::test] async fn mounted_mcp_route_requires_external_api_key() { let state = AppState::new(AppConfig::default()).expect("test state should build"); let mut errors = Vec::new(); for authorization in [None, Some("Basic not-a-bearer-token")] { let mut request = Request::builder() .method(Method::POST) .uri("/api/external/v1/mcp") .header(HOST, "localhost") .header("x-request-id", "mcp-auth-guide-test") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json, text/event-stream"); if let Some(authorization) = authorization { request = request.header(AUTHORIZATION, authorization); } let request = request .body(Body::from( r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test-agent","version":"1.0"}}}"#, )) .expect("initialize request should build"); let response = modules::external_api::router(state.clone()) .with_state(state.clone()) .layer(middleware::from_fn(attach_request_context)) .oneshot(request) .await .expect("external router should be infallible"); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert!(response.headers().get("mcp-session-id").is_none()); assert!( response .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.starts_with("application/json")) ); assert_eq!( response .headers() .get("www-authenticate") .and_then(|value| value.to_str().ok()), Some("Bearer realm=\"genarrative-external-editor\"") ); let payload: Value = serde_json::from_slice( &response .into_body() .collect() .await .expect("authentication guide should read") .to_bytes(), ) .expect("authentication guide should be JSON"); assert_eq!(payload["error"]["code"], json!("UNAUTHORIZED")); assert_eq!( payload["error"]["message"], json!("连接陶泥儿托管 MCP 需要开发者 API Key") ); assert_eq!( payload["error"]["details"]["guide"]["reason"], json!("MCP_AUTHENTICATION_REQUIRED") ); assert_eq!( payload["error"]["details"]["guide"]["action"], json!("CONFIGURE_BEARER_API_KEY") ); assert_eq!( payload["error"]["details"]["guide"]["authentication"]["valueFormat"], json!("Bearer ") ); assert_eq!( payload["error"]["details"]["guide"]["publicDiscovery"]["manifest"], json!("/api/external/v1/agent-integration.json") ); assert_eq!( payload["error"]["details"]["guide"]["credentialSafety"]["neverPasteIntoChat"], json!(true) ); assert_eq!(payload["meta"]["requestId"], json!("mcp-auth-guide-test")); assert_eq!( payload["meta"]["operation"], json!("POST /api/external/v1/mcp") ); let error = payload["error"].clone(); let serialized = payload.to_string(); assert!(!serialized.contains("tools")); assert!(!serialized.contains("resources")); assert!(!serialized.contains("provider")); assert!(!serialized.contains("procedure")); assert!(!serialized.contains("owner")); assert!(!serialized.contains("SENSITIVE_KEY_LURE")); errors.push(error); } assert_eq!(errors[0], errors[1]); } }