aa8e3507d1
新增外部去背景 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>
1294 lines
48 KiB
Rust
1294 lines
48 KiB
Rust
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 ¶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<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]);
|
||
}
|
||
}
|