Files
Genarrative/server-rs/crates/api-server/src/external_mcp.rs
T
kdletters fc14190f58
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
图集切片模式改为必须显式声明并补齐决策要求 (#408)
## 背景

切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。

## 变更

- 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。
- 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。
- AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。
- 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。
- 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。
- 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。
- 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。

## 兼容性影响

省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。

## 验证

- 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。
- AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。
- 前端:182 条定向测试与 `typecheck` 通过。
- 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。
- 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408
2026-09-17 18:10:34 +08:00

1294 lines
49 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;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。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]);
}
}