为未鉴权MCP返回接入引导

保持401与Bearer挑战并提供机器可读的Key配置步骤

统一缺失格式错误和无效Key响应以避免凭据枚举

保留请求元信息并同步OpenAPI集成清单与架构文档

补充鉴权脱敏契约测试并完成本地dev栈验证
This commit is contained in:
2026-08-03 12:03:50 +08:00
parent 6a02c7fb38
commit d1a657b5d2
7 changed files with 456 additions and 26 deletions
@@ -178,7 +178,23 @@
"description": "MCP notification 已接受"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
"description": "缺少、格式错误或无法验证 Bearer API Key。返回 WWW-Authenticate 以及机器可读的 MCP 鉴权引导,说明 Header 格式、开发者 API Key 创建位置、凭据安全要求和公开 discovery/Skill/OpenAPI 地址;不暴露 tools、resources、owner 或 Key 是否存在。",
"headers": {
"WWW-Authenticate": {
"description": "Bearer 鉴权挑战。",
"schema": {
"type": "string",
"const": "Bearer realm=\"genarrative-external-editor\""
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/McpAuthenticationGuideResponse"
}
}
}
}
}
}
@@ -2736,6 +2752,198 @@
"JsonValue": {
"description": "任意 JSON 值。"
},
"McpAuthenticationGuideResponse": {
"type": "object",
"required": [
"error",
"meta"
],
"additionalProperties": false,
"properties": {
"error": {
"type": "object",
"required": [
"code",
"message",
"details"
],
"additionalProperties": false,
"properties": {
"code": {
"const": "UNAUTHORIZED"
},
"message": {
"const": "连接陶泥儿托管 MCP 需要开发者 API Key"
},
"details": {
"type": "object",
"required": [
"guide"
],
"additionalProperties": false,
"properties": {
"guide": {
"type": "object",
"required": [
"reason",
"action",
"authentication",
"keyManagement",
"retry",
"steps",
"credentialSafety",
"publicDiscovery"
],
"additionalProperties": false,
"properties": {
"reason": {
"const": "MCP_AUTHENTICATION_REQUIRED"
},
"action": {
"const": "CONFIGURE_BEARER_API_KEY"
},
"authentication": {
"type": "object",
"required": [
"scheme",
"header",
"valueFormat"
],
"additionalProperties": false,
"properties": {
"scheme": {
"const": "Bearer"
},
"header": {
"const": "Authorization"
},
"valueFormat": {
"const": "Bearer <tnr_sk_...>"
}
}
},
"keyManagement": {
"type": "object",
"required": [
"navigationLabel",
"rawKeyShownOnce"
],
"additionalProperties": false,
"properties": {
"navigationLabel": {
"const": "开发者 API Key"
},
"rawKeyShownOnce": {
"const": true
}
}
},
"retry": {
"type": "object",
"required": [
"method",
"path",
"rpcMethod"
],
"additionalProperties": false,
"properties": {
"method": {
"const": "POST"
},
"path": {
"const": "/api/external/v1/mcp"
},
"rpcMethod": {
"const": "initialize"
}
}
},
"steps": {
"type": "array",
"items": {
"type": "string"
}
},
"credentialSafety": {
"type": "object",
"required": [
"rawKeyShownOnce",
"neverPasteIntoChat",
"neverStoreInRepository"
],
"additionalProperties": false,
"properties": {
"rawKeyShownOnce": {
"const": true
},
"neverPasteIntoChat": {
"const": true
},
"neverStoreInRepository": {
"const": true
}
}
},
"publicDiscovery": {
"type": "object",
"required": [
"manifest",
"skill",
"openapi"
],
"additionalProperties": false,
"properties": {
"manifest": {
"const": "/api/external/v1/agent-integration.json"
},
"skill": {
"const": "/api/external/v1/skill/SKILL.md"
},
"openapi": {
"const": "/api/external/v1/openapi.json"
}
}
}
}
}
}
}
}
},
"meta": {
"type": "object",
"required": [
"apiVersion",
"routeVersion",
"latencyMs",
"timestamp"
],
"additionalProperties": false,
"properties": {
"apiVersion": {
"type": "string"
},
"requestId": {
"type": "string"
},
"routeVersion": {
"type": "string"
},
"operation": {
"type": "string"
},
"latencyMs": {
"type": "integer",
"minimum": 0
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
}
},
"ErrorResponse": {
"type": "object",
"properties": {
@@ -5908,6 +5908,12 @@
- 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。
- 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``start-dev-stack.mjs``src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``response_stream.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导
- 决策:`/api/external/v1/mcp` 缺少、格式错误或无法验证 Bearer API Key 时继续返回相同 HTTP `401`,并增加 `WWW-Authenticate: Bearer realm="genarrative-external-editor"` 与机器可读 `details.guide`。引导只说明 Bearer Header 格式、登录后在「开发者 API Key」创建密钥、原始密钥只显示一次、凭据不得进入聊天或仓库、配置后重试 `initialize`,以及公开 manifest、Skill 与 OpenAPI 地址。
- 安全边界:三种鉴权失败不得通过 code、message、details 结构差异暴露 Key 是否存在;未鉴权响应不得包含 MCP tools、resources、owner 或内部鉴权诊断。其它 External v1 业务路由继续使用原通用 401,不继承 MCP 专用引导。
- 关联:`server-rs/crates/api-server/src/external_api_auth.rs``server-rs/crates/api-server/src/modules/external_api.rs``docs/openapi/genarrative-external-v1.openapi.json``docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。
@@ -93,6 +93,8 @@ Skill 日后新增 `references/` 文档时,MCP 必须按包内相对路径逐
MCP 必须始终复用 `require_external_api_key`owner 从 `ExternalApiPrincipal` 获取,不接受请求参数伪造 owner。禁止透传内部 `external_generation_job` procedure、worker controller、SpacetimeDB MCP 或 lease/fencing 控制面。
MCP 缺少、格式错误或无法验证 Bearer API Key 时仍返回 HTTP `401`,但不能只返回通用“未授权访问”。响应必须附带 `WWW-Authenticate: Bearer realm="genarrative-external-editor"`,并在安全 JSON `details.guide` 中给出稳定 `reason=MCP_AUTHENTICATION_REQUIRED``action=CONFIGURE_BEARER_API_KEY``Authorization: Bearer <tnr_sk_...>` 格式、登录后前往「开发者 API Key」创建密钥、原始密钥只显示一次、不得粘贴到聊天或写入仓库、配置后重试 `initialize` 的结构化信息,以及公开 manifest、Skill 入口和 OpenAPI 地址。三种失败使用同一响应,不得通过文案或结构差异枚举 Key 是否存在;引导不得匿名暴露 tools、resources 或 owner 信息。`agent-integration.json``mcp.credentialSetup` 同步提供 action、Header 值格式、导航标签和公开 Skill 引导地址,不编造未纳入公开契约的账户页面 URL。
## Agent 集成发现与完整 Skill 包
`agent-integration.json` 是机器可读的统一发现入口。支持远程 MCP 的 Agent 读取其中 `mcp.transport/url/authentication`,通过 MCP resources 读取 Skill 入口和所需 references,直接调用 MCP tools,不安装 CLI。仅不支持 MCP,或需要在 Agent 所在机器上编排本地文件上传的调用方下载 `skill.archive`,核对 `archiveSha256`,解压后从 `genarrative-external-editor-api/SKILL.md` 进入。
@@ -253,7 +255,7 @@ docs/openapi/genarrative-external-v1.openapi.json
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
- OpenAPI JSON 不包含 `/api/profile/api-keys``UserAccessToken` 或 API Key 管理 schema。
- `agent-integration.json` 能发现 MCP、OpenAPI、Skill entry/archive;下载 archive 的 SHA-256 与 manifest 一致,ZIP 包含 `SKILL.md`、四篇 references、Python helper 和 `agents/openai.yaml` 七个声明文件且不含凭据。
- MCP 在无 Bearer 时返回 `401`合法 Key 可完成 initialize、tools/list、resources/list/read 和生成提交/查询;resource catalog 必须包含 usage、OpenAPI、`skill` 主入口和当前全部 Skill references,当前精确为 `skill/references/capability-routing.md``skill/references/api-operations.md``skill/references/authentication-and-safety.md``skill/references/requests-and-outputs.md`,且不包含 CLI 脚本、测试或 workflow;多实例不依赖 sticky session,不暴露内部 SpacetimeDB MCP 或 worker 控制面。
- MCP 在无 Bearer、Bearer 格式错误或 Key 无效时返回相同的 `401 + WWW-Authenticate + details.guide` 鉴权引导,且不暴露 tools/resources/owner合法 Key 可完成 initialize、tools/list、resources/list/read 和生成提交/查询;resource catalog 必须包含 usage、OpenAPI、`skill` 主入口和当前全部 Skill references,当前精确为 `skill/references/capability-routing.md``skill/references/api-operations.md``skill/references/authentication-and-safety.md``skill/references/requests-and-outputs.md`,且不包含 CLI 脚本、测试或 workflow;多实例不依赖 sticky session,不暴露内部 SpacetimeDB MCP 或 worker 控制面。
- 外部素材库接口覆盖当前已有素材操作:直传凭证、素材对象确认、签名读取、读取素材库、创建 / 更新 / 删除文件夹、创建 / 更新 / 删除素材、创建项目画布资源。
- 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。
- 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。
@@ -1,9 +1,13 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
http::{
HeaderMap, HeaderValue, StatusCode,
header::{AUTHORIZATION, WWW_AUTHENTICATE},
},
middleware::Next,
response::Response,
};
use serde_json::json;
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
use tracing::warn;
@@ -74,6 +78,71 @@ pub async fn require_external_api_key(
Ok(response)
}
pub async fn require_external_mcp_api_key(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
let request_context = request.extensions().get::<RequestContext>().cloned();
match require_external_api_key(State(state), request, next).await {
Ok(response) => Ok(response),
Err(error) if error.status_code() == StatusCode::UNAUTHORIZED => {
Ok(map_external_mcp_authentication_error(error)
.into_response_with_context(request_context.as_ref()))
}
Err(error) => Err(error),
}
}
fn map_external_mcp_authentication_error(error: AppError) -> AppError {
debug_assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
external_mcp_authentication_guide_error()
}
fn external_mcp_authentication_guide_error() -> AppError {
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("连接陶泥儿托管 MCP 需要开发者 API Key")
.with_details(json!({
"guide": {
"reason": "MCP_AUTHENTICATION_REQUIRED",
"action": "CONFIGURE_BEARER_API_KEY",
"authentication": {
"scheme": "Bearer",
"header": "Authorization",
"valueFormat": "Bearer <tnr_sk_...>"
},
"keyManagement": {
"navigationLabel": "开发者 API Key",
"rawKeyShownOnce": true
},
"retry": {
"method": "POST",
"path": "/api/external/v1/mcp",
"rpcMethod": "initialize"
},
"steps": [
"登录陶泥儿,在「开发者 API Key」中创建密钥;原始密钥只显示一次",
"把密钥配置为 MCP 连接的 Bearer token;不要粘贴到聊天或写入仓库",
"使用相同 MCP URL 重新发送 initialize"
],
"credentialSafety": {
"rawKeyShownOnce": true,
"neverPasteIntoChat": true,
"neverStoreInRepository": true
},
"publicDiscovery": {
"manifest": "/api/external/v1/agent-integration.json",
"skill": "/api/external/v1/skill/SKILL.md",
"openapi": "/api/external/v1/openapi.json"
}
}
}))
.with_header(
WWW_AUTHENTICATE.as_str(),
HeaderValue::from_static("Bearer realm=\"genarrative-external-editor\""),
)
}
fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError> {
let authorization = headers
.get(AUTHORIZATION)
@@ -89,3 +158,32 @@ fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError>
.map(ToOwned::to_owned)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_authentication_guide_replaces_sensitive_key_diagnostics() {
let error = AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "external-api-key",
"message": "SENSITIVE_KEY_LURE 不存在或已失效"
}));
let mapped = map_external_mcp_authentication_error(error);
let serialized = serde_json::to_string(
mapped
.details()
.expect("mapped authentication error should contain guide details"),
)
.expect("guide should serialize");
assert_eq!(mapped.status_code(), StatusCode::UNAUTHORIZED);
assert_eq!(mapped.message(), "连接陶泥儿托管 MCP 需要开发者 API Key");
assert!(serialized.contains("MCP_AUTHENTICATION_REQUIRED"));
assert!(serialized.contains("CONFIGURE_BEARER_API_KEY"));
assert!(!serialized.contains("SENSITIVE_KEY_LURE"));
assert!(!serialized.contains("provider"));
assert!(!serialized.contains("不存在"));
assert!(!serialized.contains("已失效"));
}
}
+121 -20
View File
@@ -599,10 +599,13 @@ fn camel_to_snake(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
use axum::http::{
StatusCode,
header::{ACCEPT, HOST},
use crate::{config::AppConfig, request_context::attach_request_context};
use axum::{
http::{
StatusCode,
header::{ACCEPT, HOST},
},
middleware,
};
#[test]
@@ -719,6 +722,31 @@ mod tests {
}
}
#[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!(
@@ -864,21 +892,94 @@ mod tests {
#[tokio::test]
async fn mounted_mcp_route_requires_external_api_key() {
let state = AppState::new(AppConfig::default()).expect("test state should build");
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 = modules::external_api::router(state.clone())
.with_state(state)
.oneshot(request)
.await
.expect("external router should be infallible");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
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]);
}
}
@@ -92,7 +92,13 @@ pub async fn get_external_agent_integration_manifest() -> Result<Json<Value>, Ap
"mcp": {
"transport": "streamable-http",
"url": "/api/external/v1/mcp",
"authentication": "bearer-api-key"
"authentication": "bearer-api-key",
"credentialSetup": {
"action": "CONFIGURE_BEARER_API_KEY",
"authorizationValueFormat": "Bearer <tnr_sk_...>",
"navigationLabel": "开发者 API Key",
"guide": "/api/external/v1/skill/SKILL.md"
}
},
"openapi": "/api/external/v1/openapi.json",
"skill": {
@@ -157,6 +163,15 @@ mod tests {
.map(|(path, _)| format!("{SKILL_ROOT}/{path}"))
.to_vec();
assert_eq!(manifest["skill"]["files"], json!(expected_files));
assert_eq!(
manifest["mcp"]["credentialSetup"],
json!({
"action": "CONFIGURE_BEARER_API_KEY",
"authorizationValueFormat": "Bearer <tnr_sk_...>",
"navigationLabel": "开发者 API Key",
"guide": "/api/external/v1/skill/SKILL.md"
})
);
assert_eq!(
manifest["skill"]["archiveSha256"],
json!(format!("{:x}", Sha256::digest(&bytes)))
@@ -7,7 +7,7 @@ use axum::{
use crate::{
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
external_api_auth::require_external_api_key,
external_api_auth::{require_external_api_key, require_external_mcp_api_key},
external_assets_api::{
confirm_external_asset_object, create_external_direct_upload_ticket,
get_external_asset_read_url,
@@ -40,7 +40,7 @@ pub fn router(state: AppState) -> Router<AppState> {
.layer(Extension(state.clone()))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
require_external_mcp_api_key,
));
Router::new()