Files
Genarrative/server-rs/crates/api-server/src/external_mcp.rs
T
lhk229 a07bff85ec
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / Backend tests (push) Successful in 3m45s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m30s
Project CI / Frontend tests (push) Successful in 1m53s
Project CI / Native shell tests (push) Successful in 5m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m15s
Project CI / Repository checks (push) Successful in 1m58s
Project CI / AI game creator shell web tests (push) Successful in 1m27s
升级mcp,增加按语义分类的工具。旧工具不变 (#493)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/493
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-24 17:19:45 +08:00

1612 lines
60 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};
mod semantic;
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#"陶泥儿提供画布项目管理、素材库管理,以及图片、角色动画、视频和音频生成能力。
按用户任务需要创建或复用项目、素材文件夹,不默认创建。生成结果需要进入画布或素材库时,使用对应生成工具支持的目标字段;已有落库结果不要重复登记。
生成操作会产生费用,采用异步提交。每次独立生成使用稳定的 idempotencyKey;取得任务 ID 后,使用 check_generation 按 pollAfterMs 查询,直到 completed 或 failed。查询超时不代表生成失败,不要因此重新提交或更换幂等键。
本地参考文件通过 prepare_asset_upload 获取上传票据,由调用方实际上传后确认对象。后续引用遵循各工具要求,使用稳定的对象键或资源、素材 ID;不要把临时下载 URL 当作持久引用。
以实际返回的结果和告警判断完成情况,部分产物成功不代表所有处理步骤成功。具体参数以工具 schema 和说明为准;需要详细流程、示例或 API 契约时,通过 resources/list 查找相关文档。"#;
#[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)
.chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone()))
.collect(),
))
}
fn get_tool(&self, name: &str) -> Option<Tool> {
MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == name)
.map(mcp_operation_tool)
.or_else(|| semantic::find(name).map(|entry| entry.tool.clone()))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: McpRequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
if let Some(tool) = semantic::find(request.name.as_ref()) {
let result = match tool.prepare(request.arguments.unwrap_or_default()) {
Ok(call) => {
dispatch_operation(
call.operation,
call.arguments,
&context,
call.optional_idempotency_key,
)
.await
}
Err(error) => Err(error),
};
return Ok(match result {
Ok(value) => CallToolResult::structured(value),
Err(value) => CallToolResult::structured_error(value),
});
}
let operation = MCP_OPERATIONS
.iter()
.find(|operation| operation.tool_name == request.name.as_ref())
.ok_or_else(|| ErrorData::invalid_params("未知的陶泥儿外部 API 工具", None))?;
let arguments = request.arguments.unwrap_or_default();
match dispatch_operation(operation, arguments, &context, None).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>,
optional_idempotency_key: Option<axum::http::HeaderValue>,
) -> 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 request = build_operation_request(
operation,
&arguments,
authorization,
request_context,
optional_idempotency_key,
)?;
let response = modules::external_api::router(state.clone())
.with_state(state)
.oneshot(request)
.await
.unwrap_or_else(|never| match never {});
let status = response.status();
let bytes = response
.into_body()
.collect()
.await
.map_err(|_| json!({"error": "读取外部 API 响应失败"}))?
.to_bytes();
if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES {
return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"}));
}
let payload = serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
json!({
"status": status.as_u16(),
"message": "外部 API 返回了非 JSON 响应"
})
});
if status.is_success() {
Ok(unwrap_external_api_success_payload(payload))
} else {
Err(json!({
"status": status.as_u16(),
"response": payload,
}))
}
}
fn build_operation_request(
operation: &McpOperation,
arguments: &Map<String, Value>,
authorization: axum::http::HeaderValue,
request_context: RequestContext,
optional_idempotency_key: Option<axum::http::HeaderValue>,
) -> Result<Request<Body>, Value> {
let mut path = operation.path_template.clone();
if let Some(path_parameters) = arguments.get("pathParameters").and_then(Value::as_object) {
for (name, value) in path_parameters {
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())?;
if let Some(key) = optional_idempotency_key {
request.headers_mut().insert("idempotency-key", key);
}
Ok(request)
}
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)
.chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone()))
.collect::<Vec<_>>();
let serialized = serde_json::to_vec(&tools).expect("tool catalog should serialize");
assert!(serialized.len() < 512 * 1024);
for tool in tools {
let serialized = serde_json::to_string(&tool.input_schema)
.expect("tool input schema should serialize");
assert!(serialized.len() < 64 * 1024, "{}", tool.name);
assert!(!serialized.contains("\"$ref\""), "{}", tool.name);
assert_eq!(tool.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"})
);
let result = json!({
"operationId": "task-1", "status": "completed",
"result": {"objectKey": "media/sheet.png", "warning": {"code": "source_preserved"},
"sliceWarning": {"code": "slice_failed"}, "project": {"revision": 7}}
});
assert_eq!(
unwrap_external_api_success_payload(json!({"ok": true, "data": result})),
result
);
}
fn rpc_request(method: &str, params: Value) -> Request<Body> {
Request::builder()
.method(Method::POST)
.uri("/api/external/v1/mcp")
.header(HOST, "localhost")
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json, text/event-stream")
.header("mcp-protocol-version", "2025-11-25")
.body(Body::from(
json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string(),
))
.unwrap()
}
async fn rpc_payload(response: axum::response::Response) -> Value {
assert_eq!(response.status(), StatusCode::OK);
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap()
}
#[tokio::test]
async fn semantic_catalog_appends_tools_without_changing_legacy_definitions() {
let payload = rpc_payload(
service()
.oneshot(rpc_request("tools/list", json!({})))
.await
.unwrap()
.map(Body::new),
)
.await;
let tools = payload["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), MCP_OPERATIONS.len() + 15);
for op in MCP_OPERATIONS.iter() {
let expected = serde_json::to_value(mcp_operation_tool(op)).unwrap();
assert_eq!(
tools.iter().find(|tool| tool["name"] == op.tool_name),
Some(&expected)
);
}
for entry in semantic::TOOLS.iter() {
assert_eq!(
GenarrativeExternalMcp.get_tool(&entry.tool.name),
Some(entry.tool.clone())
);
}
}
#[tokio::test]
async fn semantic_invalid_arguments_fail_before_http_context_or_side_effects() {
for (name, arguments) in [
(
"modify_image",
json!({"action": "edit", "input": {"prompt": "修改", "sourceImageSrc": "wrong-reference"}, "idempotencyKey": "test"}),
),
(
"delete_resources",
json!({"action": "delete_project", "input": {}}),
),
(
"manage_canvas_projects",
json!({"action": "rename", "input": {"projectId": "project", "title": "新名"}, "idempotencyKey": "not-supported"}),
),
("generate_image", json!({"prompt": "test"})),
] {
let payload = rpc_payload(
service()
.oneshot(rpc_request(
"tools/call",
json!({"name": name, "arguments": arguments}),
))
.await
.unwrap()
.map(Body::new),
)
.await;
assert_eq!(payload["result"]["isError"], true, "{name}: {payload}");
assert!(payload["result"]["structuredContent"]["error"].is_string());
assert!(!payload.to_string().contains("上下文缺失"));
}
}
#[tokio::test]
async fn semantic_adapter_builds_real_rest_paths_bodies_and_optional_headers() {
for (name, args, method, path, body, key) in [
(
"manage_canvas_projects",
json!({"action":"create","input":{},"idempotencyKey":"create-project"}),
Method::POST,
"/api/external/v1/editor/projects",
json!({}),
Some("create-project"),
),
(
"manage_canvas_projects",
json!({"action":"rename","input":{"projectId":"project/a","title":"新名"}}),
Method::PATCH,
"/api/external/v1/editor/projects/project%2Fa/metadata",
json!({"title":"新名"}),
None,
),
(
"find_assets",
json!({"action":"get_download_url","input":{"objectKey":"images/a b.png","expireSeconds":60}}),
Method::GET,
"/api/external/v1/assets/read-url?expireSeconds=60&objectKey=images%2Fa+b.png",
Value::Null,
None,
),
(
"find_canvas_projects",
json!({"action":"list","input":{}}),
Method::GET,
"/api/external/v1/editor/projects?view=summary",
Value::Null,
None,
),
(
"modify_image",
json!({"action":"variation","input":{"prompt":"变体","referenceImageSrcs":["ref"]},"idempotencyKey":"same-generation"}),
Method::POST,
"/api/external/v1/editor/images/generations",
json!({"prompt":"变体","referenceImageSrcs":["ref"],"kind":"quick-edit"}),
Some("same-generation"),
),
] {
let call = semantic::find(name)
.unwrap()
.prepare(args.as_object().unwrap().clone())
.unwrap();
let context = RequestContext::new(
"test-request".into(),
"POST /api/external/v1/mcp".into(),
std::time::Duration::ZERO,
false,
);
let request = build_operation_request(
call.operation,
&call.arguments,
"Bearer fixture".parse().unwrap(),
context,
call.optional_idempotency_key,
)
.unwrap();
assert_eq!(request.method(), method);
assert_eq!(request.uri().to_string(), path);
assert_eq!(request.headers()[AUTHORIZATION], "Bearer fixture");
assert_eq!(
request
.headers()
.get("idempotency-key")
.map(|v| v.to_str().unwrap()),
key
);
assert_eq!(
request
.extensions()
.get::<RequestContext>()
.unwrap()
.request_id(),
"test-request"
);
let bytes = request.into_body().collect().await.unwrap().to_bytes();
if body.is_null() {
assert!(bytes.is_empty());
} else {
assert_eq!(serde_json::from_slice::<Value>(&bytes).unwrap(), body);
}
}
let operation = MCP_OPERATIONS
.iter()
.find(|op| op.operation_id == "createEditorProject")
.unwrap();
let mut headers = HeaderMap::new();
apply_operation_headers(
operation,
&json!({"idempotencyKey": "legacy-ignored"})
.as_object()
.unwrap()
.clone(),
&mut headers,
)
.unwrap();
assert!(
headers.get("idempotency-key").is_none(),
"old optional-header behavior must remain unchanged"
);
}
#[tokio::test]
async fn semantic_calls_reuse_rest_scope_checks_and_structured_errors() {
use crate::state::external_api_auth::ExternalApiKeyAuthenticator;
use futures_util::future::BoxFuture;
use spacetime_client::{
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClientError,
};
use std::sync::atomic::{AtomicUsize, Ordering};
struct NoScopes(AtomicUsize);
impl ExternalApiKeyAuthenticator for NoScopes {
fn authenticate_external_api_key(
&self,
_: ExternalApiKeyAuthenticateRecordInput,
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
self.0.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(ExternalApiKeyRecord {
key_id: "fixture-key".into(),
owner_user_id: "owner-from-store".into(),
name: "测试".into(),
key_prefix: "tnr_sk_fixture".into(),
scopes: vec![],
created_at: "0.000000Z".into(),
last_used_at: None,
revoked_at: None,
updated_at: "0.000000Z".into(),
})
})
}
}
let auth = Arc::new(NoScopes(AtomicUsize::new(0)));
let state = AppState::new(AppConfig::default())
.unwrap()
.with_external_api_auth_state(crate::state::ExternalApiAuthState::new(auth.clone()));
let router = modules::external_api::router(state.clone())
.with_state(state)
.layer(middleware::from_fn(attach_request_context));
for (name, arguments) in [
(
"delete_resources",
json!({"action":"delete_project","input":{"projectId":"fixture-project"}}),
),
(
"delete_editor_project",
json!({"pathParameters":{"projectId":"fixture-project"}}),
),
] {
let mut request = rpc_request("tools/call", json!({"name":name,"arguments":arguments}));
request
.headers_mut()
.insert(AUTHORIZATION, "Bearer tnr_sk_fixture".parse().unwrap());
let payload = rpc_payload(router.clone().oneshot(request).await.unwrap()).await;
assert_eq!(payload["result"]["isError"], true, "{payload}");
assert_eq!(
payload["result"]["structuredContent"]["status"], 403,
"{payload}"
);
assert!(!payload.to_string().contains("owner-from-store"));
}
assert_eq!(
auth.0.load(Ordering::Relaxed),
4,
"outer MCP and inner REST both authenticate"
);
}
#[tokio::test]
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]);
}
}