yggdrasil/mcp/router.rs
1//! 构建并返回挂载于 `/mcp` 的 axum 子路由。
2//!
3//! 装配:
4//! - rmcp `StreamableHttpService`(无状态、JSON 响应、Origin 白名单 + Host 白名单);
5//! - 本 crate 的 `mcp_auth_middleware`(bearer → McpPrincipal,注入 extensions);
6//!
7//! 两道白名单都源自 `APP_BASE_URL`:
8//! - **Origin**:整串(含 scheme),rmcp 按 scheme+host 校验 `Origin` 头;
9//! - **Host**:仅 host 部分,加入 rmcp 的 DNS-rebinding 防护默认表(localhost/127.0.0.1/::1)。
10//! 生产反代 `proxy_set_header Host $host` 会把真实域名转发进来,必须加入否则 rmcp 一律 403。
11//!
12//! 未设置 `APP_BASE_URL` 则两者均空(rmcp 对缺 Origin 放行;Host 回退默认表仅本地可用)。
13//! 开发期额外放行 `0.0.0.0`(dx 代理改写 Host 的容错),生产域名态保持严格。
14//! 生产部署 MUST 设置 `APP_BASE_URL`(见 docs/DEPLOYMENT.md)。
15//! 协议版本头、体积上限(4MiB 默认)由 rmcp 内置,无需此处重复。
16
17use axum::middleware::from_fn;
18use axum::Router;
19use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
20use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
21
22use crate::mcp::auth::mcp_auth_middleware;
23use crate::mcp::server::YggMcpServer;
24
25/// 构建 `/mcp` 子路由(供 main.rs 的 `.merge(mcp_route)` 合并)。
26///
27/// 仅在 server feature 下有意义;WASM 构建不会调用本函数。
28pub fn mcp_route() -> Router {
29 // 读取 APP_BASE_URL:同时用于 Origin 白名单(整串含 scheme)与 Host 白名单(仅 host)。
30 let base_url = std::env::var("APP_BASE_URL")
31 .ok()
32 .map(|s| s.trim().to_string())
33 .filter(|s| !s.is_empty());
34
35 // Origin 白名单:APP_BASE_URL 整串(含 scheme),rmcp 按 scheme+host[:port] 匹配 Origin 头。
36 // 未设置则空——rmcp 对缺 Origin 头的请求放行(大多数原生 MCP 客户端不发 Origin)。
37 let allowed_origins: Vec<String> = base_url.iter().cloned().collect();
38
39 // Host 白名单(rmcp 的 DNS-rebinding 防护)。默认仅允许 localhost/127.0.0.1/::1:
40 // 生产反代(nginx `proxy_set_header Host $host`)会把真实域名转发进来,若不把
41 // APP_BASE_URL 的 host 也加入,所有 MCP 请求会被 rmcp 以 403 拒绝。保留默认值,
42 // 确保本地直连(localhost)仍可用;只取 host(不含 port),rmcp 对无 port 条目匹配任意 port。
43 let mut allowed_hosts: Vec<String> = vec!["localhost".into(), "127.0.0.1".into(), "::1".into()];
44 if let Some(base) = &base_url {
45 if let Ok(uri) = http::Uri::try_from(base.as_str()) {
46 if let Some(authority) = uri.authority() {
47 let host = authority.host().to_lowercase();
48 if !allowed_hosts.contains(&host) {
49 allowed_hosts.push(host);
50 }
51 }
52 }
53 }
54
55 // 开发期容错:APP_BASE_URL 未设或仍是 localhost 系(开发态)时,额外放行 0.0.0.0。
56 // 原因:`dx serve --addr 0.0.0.0` 转发到后端原生 server 时会把 Host 头改写成
57 // `0.0.0.0:<随机端口>`(原生 server 端口每次重编译变化),rmcp 据此判 rebinding 攻击
58 // 一律 403——导致 MCP 在 dx 开发代理(:8080)下完全不可用。0.0.0.0 仅在本地开发无攻击
59 // 价值,故仅在未配置生产域名时放行;生产域名态保持严格。
60 let is_dev = base_url
61 .as_deref()
62 .and_then(|b| http::Uri::try_from(b).ok())
63 .and_then(|u| u.authority().map(|a| a.host().to_lowercase()))
64 .map(|h| matches!(h.as_str(), "localhost" | "127.0.0.1" | "::1"))
65 .unwrap_or(true); // 未设 APP_BASE_URL 视为开发态
66 if is_dev && !allowed_hosts.iter().any(|h| h == "0.0.0.0") {
67 allowed_hosts.push("0.0.0.0".into());
68 }
69
70 let mut config = StreamableHttpServerConfig::default()
71 .with_legacy_session_mode(false) // 无状态(SEP-2567)
72 .with_json_response(true) // 简单工具用 application/json 直回
73 .with_allowed_hosts(allowed_hosts.iter().map(|s| s.as_str()));
74 if !allowed_origins.is_empty() {
75 config = config.with_allowed_origins(allowed_origins.iter().map(|s| s.as_str()));
76 }
77
78 let service = StreamableHttpService::new(
79 || Ok::<_, std::io::Error>(YggMcpServer),
80 LocalSessionManager::default().into(),
81 config,
82 );
83
84 Router::new()
85 .nest_service("/mcp", service)
86 .layer(from_fn(mcp_auth_middleware))
87}