Files
Genarrative/server-rs/crates/api-server/src/external_mcp.rs
T
suzmii aa8e3507d1
Project CI / Repository checks (push) Successful in 4m20s
Project CI / Frontend tests (push) Successful in 4m31s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / Native shell tests (push) Successful in 14m53s
新增 External v1 去背景生成链路 (#184)
新增外部去背景 API、MCP 工具与异步队列契约

补齐来源归属、媒体类型、幂等重放和画布原子持久化校验

修复 provenance 重建、assetKindOverride 门禁与 revision retry 竞态

同步 Python helper、Skill、OpenAPI 及项目文档

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/184
Co-authored-by: suzmii <suzmii@foxmail.com>
Co-committed-by: suzmii <suzmii@foxmail.com>
2026-08-24 14:37:32 +08:00

1294 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Map<String, Value>>,
requires_idempotency_key: bool,
}
static MCP_OPERATIONS: LazyLock<Vec<McpOperation>> = LazyLock::new(build_mcp_operations);
#[derive(Clone, Debug, Default)]
pub(crate) struct GenarrativeExternalMcp;
pub(crate) type GenarrativeExternalMcpService =
StreamableHttpService<GenarrativeExternalMcp, LocalSessionManager>;
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<PaginatedRequestParams>,
_context: McpRequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
Ok(ListToolsResult::with_all_items(
MCP_OPERATIONS.iter().map(mcp_operation_tool).collect(),
))
}
fn get_tool(&self, name: &str) -> Option<Tool> {
MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == name)
.map(mcp_operation_tool)
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: McpRequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
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<PaginatedRequestParams>,
_context: McpRequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
Ok(ListResourcesResult::with_all_items(mcp_resources()))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: McpRequestContext<RoleServer>,
) -> Result<ReadResourceResult, ErrorData> {
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<Resource> {
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<McpOperation> {
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<String, Value>,
operation: &Map<String, Value>,
) -> 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<String, Value>,
operation: &Map<String, Value>,
requires_idempotency_key: bool,
) -> Map<String, Value> {
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::<Vec<_>>();
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 &parameters {
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<String, Value>,
context: &McpRequestContext<RoleServer>,
) -> Result<Value, Value> {
validate_required_body(operation, &arguments)?;
let parts = context
.extensions
.get::<axum::http::request::Parts>()
.ok_or_else(|| json!({"error": "MCP HTTP 请求上下文缺失"}))?;
let state = parts
.extensions
.get::<AppState>()
.cloned()
.ok_or_else(|| json!({"error": "MCP 应用状态缺失"}))?;
let request_context = parts
.extensions
.get::<RequestContext>()
.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::<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 apply_operation_headers(
operation: &McpOperation,
arguments: &Map<String, Value>,
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<String, Value>,
) -> 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::<Vec<_>>();
if missing_fields.is_empty() {
Ok(())
} else {
Err(json!({
"error": "缺少必填字段",
"missingFields": missing_fields,
}))
}
}
fn append_query_parameters(path: &mut String, query: &Map<String, Value>) {
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<String, Value>,
) {
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<String> {
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::<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)
.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 <tnr_sk_...>")
);
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]);
}
}