diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index aa64acfac..86144242b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4098,3 +4098,11 @@ - 风险:Provider 工具约束不是本地安全边界;特别是 `writes + readOnlyHint=true` 自动放行的工具,schema 外字段可能改变外部副作用而不进入预期确认路径。 - 处理:使用完整 JSON Schema validator 校验原始 catalog schema,不手写 required/type 子集;native parser、fingerprint enrichment 与实际 MCP 调用边界复用同一校验器。enrichment 错误必须映射回 classified `arguments-schema` repair,不能以普通字符串直接终止 run;执行点重验用于阻断升级前已经落盘的 schema 外 pending。关闭网络和文件 `$ref` 解析,schema 无法安全编译时不广告或不执行。`serde` 类型错误会包含实际字符串值,catalog miss 也会包含模型提交的 server/tool,因此这两类错误同样只能返回稳定类别,不能拼接原始错误、参数值或 schema 内容。 - 验证:覆盖 required、additionalProperties、type、enum、本地 `$defs/$ref`、HTTP/file 外部引用、无效 schema、错误脱敏,证明 legacy wrapper 在注入 fingerprint 前进入 repair,并证明带旧有效 fingerprint 的历史 pending 在实际调用前仍被 schema 拒绝。 + +## 托管 MCP 新增公开域名时不能只更新网关路由(2026-08-05) + +- 现象:`https://dev.genarrative.world/api/external/v1/mcp` 的 manifest、OpenAPI 和 Bearer 鉴权都正常,但鉴权后的 `initialize` 返回 `403 FORBIDDEN`;通过 SSH 隧道访问同一 api-server 的 loopback 地址却可以正常列出 tools/resources。 +- 原因:`rmcp` Streamable HTTP transport 自带 DNS rebinding 防护。公网网关已经接入 dev 域名,但 `external_mcp::service()` 的 `allowed_hosts` / `allowed_origins` 仍只登记正式域名和 localhost,因此请求在 MCP 协议处理前被 transport 拒绝。 +- 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 `Host`、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。 +- 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize`、`tools/list`、`resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。 +- 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index fbac43ea3..876b2e86b 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -77,6 +77,8 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原 `/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize`、`notifications/initialized`、`ping`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。 +MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarrative.world` / `genarrative.world`、开发入口 `dev.genarrative.world` 和本机开发入口;对应 HTTPS Origin 也必须与公开环境同步登记。新增公开环境域名时,必须在发布前使用该域名的真实 `Host` 和 `Origin` 执行 `initialize` 回归,不能只用 `localhost` 单测证明端点可用。 + MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。 MCP 暴露下列稳定文本资源: diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index a7f9d60fe..5f9a056b9 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -83,6 +83,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService { .with_allowed_hosts([ "www.genarrative.world", "genarrative.world", + "dev.genarrative.world", "localhost", "127.0.0.1", "::1", @@ -90,6 +91,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService { .with_allowed_origins([ "https://www.genarrative.world", "https://genarrative.world", + "https://dev.genarrative.world", "http://localhost:3000", "http://127.0.0.1:3000", ]); @@ -603,7 +605,7 @@ mod tests { use axum::{ http::{ StatusCode, - header::{ACCEPT, HOST}, + header::{ACCEPT, HOST, ORIGIN}, }, middleware, }; @@ -759,6 +761,47 @@ mod tests { ); } + #[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()