为未鉴权MCP返回接入引导
保持401与Bearer挑战并提供机器可读的Key配置步骤 统一缺失格式错误和无效Key响应以避免凭据枚举 保留请求元信息并同步OpenAPI集成清单与架构文档 补充鉴权脱敏契约测试并完成本地dev栈验证
This commit is contained in:
@@ -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("已失效"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user