允许开发环境访问托管MCP
将dev公开域名加入MCP Host与Origin白名单 新增正负例锁定DNS rebinding防护 同步外部API文档和项目排障经验
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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 暴露下列稳定文本资源:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user