diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 2f14023cd..0d7a2692a 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -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 " + } + } + }, + "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": { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 481e51d03..f67a51b93 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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,不得换键重提。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 95df5f0bb..fbac43ea3 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -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 ` 格式、登录后前往「开发者 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 设计图生成与拆分、角色动画、视频、音效和背景音乐。 diff --git a/server-rs/crates/api-server/src/external_api_auth.rs b/server-rs/crates/api-server/src/external_api_auth.rs index e7ee85b29..5f6f5044d 100644 --- a/server-rs/crates/api-server/src/external_api_auth.rs +++ b/server-rs/crates/api-server/src/external_api_auth.rs @@ -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, + request: Request, + next: Next, +) -> Result { + let request_context = request.extensions().get::().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 " + }, + "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 { let authorization = headers .get(AUTHORIZATION) @@ -89,3 +158,32 @@ fn extract_external_api_bearer(headers: &HeaderMap) -> Result .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("已失效")); + } +} diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index 6f5c17dfa..a7f9d60fe 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -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 ") + ); + 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]); } } diff --git a/server-rs/crates/api-server/src/external_skill_api.rs b/server-rs/crates/api-server/src/external_skill_api.rs index c072c2bf6..2472b90ea 100644 --- a/server-rs/crates/api-server/src/external_skill_api.rs +++ b/server-rs/crates/api-server/src/external_skill_api.rs @@ -92,7 +92,13 @@ pub async fn get_external_agent_integration_manifest() -> Result, 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 ", + "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 ", + "navigationLabel": "开发者 API Key", + "guide": "/api/external/v1/skill/SKILL.md" + }) + ); assert_eq!( manifest["skill"]["archiveSha256"], json!(format!("{:x}", Sha256::digest(&bytes))) diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index 4c1b5757f..ac2fbf362 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -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 { .layer(Extension(state.clone())) .route_layer(middleware::from_fn_with_state( state.clone(), - require_external_api_key, + require_external_mcp_api_key, )); Router::new()