Skip to main content

futu_mcp/
http.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use anyhow::{Context, Result};
5use futu_auth::KeyStore;
6
7use crate::tools;
8
9/// HTTP 模式:axum + rmcp StreamableHttpService,`/mcp` 路径跑 MCP,
10/// `/metrics` 暴露 Prometheus counters(无需 token),
11/// `/.well-known/oauth-protected-resource` 暴露 OAuth2 Protected Resource
12/// Metadata(RFC 9728,给 MCP 客户端发现鉴权要求用)。
13///
14/// v1.4+:未带 Bearer token 的 `/mcp` 请求会回 `401 + WWW-Authenticate`
15/// 头,指向 resource metadata,配合 rmcp 客户端的自动发现流程。
16pub(super) fn render_mcp_metrics_body() -> String {
17    let registry = futu_auth::metrics::global();
18    render_mcp_metrics_body_for(registry.as_deref())
19}
20
21pub(super) fn render_mcp_metrics_body_for(registry: Option<&futu_auth::MetricsRegistry>) -> String {
22    registry.map(|r| r.render_prometheus()).unwrap_or_else(|| {
23        concat!(
24            "# HELP futu_metrics_registry_installed Whether futu_auth metrics registry is installed (1=yes, 0=no)\n",
25            "# TYPE futu_metrics_registry_installed gauge\n",
26            "futu_metrics_registry_installed{state=\"metrics registry not installed\"} 0\n"
27        )
28        .to_string()
29    })
30}
31
32pub(super) async fn serve_http(
33    server: tools::FutuServer,
34    key_store: Arc<KeyStore>,
35    listen: &str,
36    tls: Option<(PathBuf, PathBuf)>,
37) -> Result<()> {
38    use rmcp::transport::streamable_http_server::{
39        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
40    };
41
42    // 补齐 `:port` 写法:bind 到 0.0.0.0:port
43    let bind_addr = if listen.starts_with(':') {
44        format!("0.0.0.0{listen}")
45    } else {
46        listen.to_string()
47    };
48
49    // rmcp StreamableHttpService 要求一个 service factory —— 每个 HTTP 会话要
50    // 一份独立的 MCP Service 实例。ServerState 跨会话共享,但 legacy
51    // logging/setLevel 阈值属于会话,必须由 FutuServer::new 逐会话创建。
52    let session_manager = std::sync::Arc::new(LocalSessionManager::default());
53    // One endpoint serves both lifecycles. Legacy versions retain their
54    // initialize-created sessions, while rmcp always routes 2026-07-28
55    // requests statelessly (SEP-2567). Do not enable the global strict
56    // metadata switch: rmcp 1.x/2.x clients do not attach modern per-request
57    // metadata. Default loopback Host validation remains active.
58    let transport_config = StreamableHttpServerConfig::default()
59        .with_legacy_session_mode(true)
60        .with_stateless_protocol_metadata_required(false);
61    let mcp_svc = StreamableHttpService::new(
62        {
63            let state = server.state.clone();
64            move || Ok::<_, std::io::Error>(crate::tools::FutuServer::new(state.clone()))
65        },
66        session_manager,
67        transport_config,
68    );
69
70    // axum 0.8 router:
71    // - /mcp        → MCP tower service(带 WWW-Authenticate 401 middleware)
72    // - /metrics    → Prometheus 文本
73    // - /.well-known/oauth-protected-resource → RFC 9728 元数据
74    use axum::routing::get;
75    let mcp_with_auth_hint = axum::Router::new()
76        .nest_service("/mcp", mcp_svc)
77        .layer(axum::middleware::from_fn_with_state(
78            key_store,
79            authenticate_mcp_transport,
80        ))
81        .layer(axum::middleware::from_fn(inject_www_authenticate));
82
83    let app = axum::Router::new()
84        .route(
85            "/metrics",
86            get(|| async {
87                let body = render_mcp_metrics_body();
88                (
89                    axum::http::StatusCode::OK,
90                    [(
91                        axum::http::header::CONTENT_TYPE,
92                        "text/plain; version=0.0.4",
93                    )],
94                    body,
95                )
96            }),
97        )
98        .route(
99            "/.well-known/oauth-protected-resource",
100            get(oauth_protected_resource_metadata),
101        )
102        .merge(mcp_with_auth_hint);
103
104    let bind_addr_sock: std::net::SocketAddr = bind_addr
105        .parse()
106        .map_err(|e| anyhow::anyhow!("invalid bind address {bind_addr}: {e}"))?;
107
108    if let Some((cert_path, key_path)) = tls {
109        // ---------- HTTPS(graceful shutdown 通过 Handle)----------
110        let tls_config =
111            axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
112                .await
113                .with_context(|| {
114                    format!(
115                        "load TLS cert={} key={}",
116                        cert_path.display(),
117                        key_path.display()
118                    )
119                })?;
120        let handle = axum_server::Handle::new();
121        let shutdown_handle = handle.clone();
122        tokio::spawn(async move {
123            shutdown_signal().await;
124            tracing::info!("graceful shutdown: draining HTTPS connections...");
125            shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(10)));
126        });
127        tracing::info!(
128            addr = %bind_addr,
129            cert = %cert_path.display(),
130            "futu-mcp HTTPS transport started \
131             (MCP: /mcp, metrics: /metrics, OAuth metadata: /.well-known/oauth-protected-resource)"
132        );
133        axum_server::bind_rustls(bind_addr_sock, tls_config)
134            .handle(handle)
135            .serve(app.into_make_service())
136            .await
137            .map_err(|e| anyhow::anyhow!("axum-server TLS serve error: {e}"))?;
138    } else {
139        // ---------- plain HTTP(graceful shutdown 通过 axum::serve)----------
140        let listener = tokio::net::TcpListener::bind(&bind_addr)
141            .await
142            .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
143        tracing::info!(
144            addr = %bind_addr,
145            "futu-mcp HTTP transport started \
146             (MCP: /mcp, metrics: /metrics, OAuth metadata: /.well-known/oauth-protected-resource)"
147        );
148        axum::serve(listener, app)
149            .with_graceful_shutdown(async {
150                shutdown_signal().await;
151                tracing::info!("graceful shutdown: draining HTTP connections...");
152            })
153            .await
154            .map_err(|e| anyhow::anyhow!("axum serve error: {e}"))?;
155    }
156    tracing::info!("server stopped");
157    Ok(())
158}
159
160/// Scope-mode transport authentication boundary for every `/mcp` request.
161///
162/// This runs outside rmcp's [`StreamableHttpService`], so a rejected request
163/// cannot create, inspect, stream, or close a session. Tool-level scope,
164/// account, and quota checks remain inside the MCP handlers.
165pub(super) async fn authenticate_mcp_transport(
166    axum::extract::State(key_store): axum::extract::State<Arc<KeyStore>>,
167    req: axum::extract::Request,
168    next: axum::middleware::Next,
169) -> axum::response::Response {
170    if !key_store.is_configured() {
171        return next.run(req).await;
172    }
173
174    let mut values = req
175        .headers()
176        .get_all(axum::http::header::AUTHORIZATION)
177        .iter();
178    let Some(value) = values.next() else {
179        return mcp_transport_unauthorized();
180    };
181    if values.next().is_some() {
182        return mcp_transport_unauthorized();
183    }
184    let Some(token) = value
185        .to_str()
186        .ok()
187        .and_then(futu_auth_pipeline::parse_bearer_scheme)
188    else {
189        return mcp_transport_unauthorized();
190    };
191    if key_store.verify(token).is_none() {
192        return mcp_transport_unauthorized();
193    }
194
195    next.run(req).await
196}
197
198fn mcp_transport_unauthorized() -> axum::response::Response {
199    use axum::response::IntoResponse;
200
201    (
202        axum::http::StatusCode::UNAUTHORIZED,
203        [(
204            axum::http::header::WWW_AUTHENTICATE,
205            "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"",
206        )],
207    )
208        .into_response()
209}
210
211/// 监听 SIGTERM / SIGINT,任一到达即返回。
212/// 同时兼容 Windows(只有 ctrl_c)和 Unix(SIGTERM + SIGINT)。
213async fn shutdown_signal() {
214    #[cfg(unix)]
215    {
216        use tokio::signal::unix::{SignalKind, signal};
217        let sigterm = match signal(SignalKind::terminate()) {
218            Ok(signal) => Some(signal),
219            Err(e) => {
220                tracing::error!(error = %e, "failed to install SIGTERM handler");
221                None
222            }
223        };
224        let sigint = match signal(SignalKind::interrupt()) {
225            Ok(signal) => Some(signal),
226            Err(e) => {
227                tracing::error!(error = %e, "failed to install SIGINT handler");
228                None
229            }
230        };
231
232        match (sigterm, sigint) {
233            (Some(mut sigterm), Some(mut sigint)) => {
234                tokio::select! {
235                    _ = sigterm.recv() => tracing::info!("received SIGTERM"),
236                    _ = sigint.recv()  => tracing::info!("received SIGINT"),
237                }
238            }
239            (Some(mut sigterm), None) => {
240                sigterm.recv().await;
241                tracing::info!("received SIGTERM");
242            }
243            (None, Some(mut sigint)) => {
244                sigint.recv().await;
245                tracing::info!("received SIGINT");
246            }
247            (None, None) => wait_for_ctrl_c_or_pending().await,
248        }
249    }
250    #[cfg(not(unix))]
251    {
252        wait_for_ctrl_c_or_pending().await;
253    }
254}
255
256async fn wait_for_ctrl_c_or_pending() {
257    match tokio::signal::ctrl_c().await {
258        Ok(()) => tracing::info!("received Ctrl-C"),
259        Err(e) => {
260            tracing::error!(
261                error = %e,
262                "failed to install ctrl-c handler; graceful shutdown signal unavailable"
263            );
264            std::future::pending::<()>().await;
265        }
266    }
267}
268
269/// RFC 9728 — OAuth 2.0 Protected Resource Metadata
270///
271/// 我们不是完整 OAuth 授权服务器(那需要独立的 IdP),只是告诉 MCP 客户端
272/// "这个资源要 Bearer token,scope 列表如下"。LLM agent 实际拿到 key 的方式
273/// 仍然是运维线下发放 + 写入 MCP client 配置里的 `Authorization` 头。
274///
275/// 客户端可以 GET `/.well-known/oauth-protected-resource` 来发现:
276///   - `resource`:              本 MCP endpoint URI
277///   - `bearer_methods_supported`: 我们只支持 `header`(Authorization: Bearer ...)
278///   - `scopes_supported`:      可声明的 futu-auth scope 列表
279///   - `resource_name` / `resource_documentation`: 给人看的说明
280pub(super) async fn oauth_protected_resource_metadata() -> axum::response::Json<serde_json::Value> {
281    axum::response::Json(serde_json::json!({
282        "resource": "/mcp",
283        "bearer_methods_supported": ["header"],
284        "scopes_supported": [
285            "qot:read",
286            "acc:read",
287            "trade:simulate",
288            "trade:real",
289            "trade:unlock"
290        ],
291        "resource_name": "FutuOpenD-rs MCP",
292        "resource_documentation": "https://futuapi.com/reference/mcp/",
293    }))
294}
295
296/// Tower middleware: 如果下游(MCP service)返回 401/403 又没 `WWW-Authenticate`
297/// 头,补一个 `Bearer resource_metadata="..."`,指向 `/.well-known/oauth-protected-resource`。
298///
299/// 符合 RFC 9728 §5.1:资源服务器应通过 WWW-Authenticate 宣告 metadata URL,
300/// 让未配置的客户端能自动发现 scope 和鉴权方式。
301pub(super) async fn inject_www_authenticate(
302    req: axum::extract::Request,
303    next: axum::middleware::Next,
304) -> axum::response::Response {
305    let mut resp = next.run(req).await;
306    let status = resp.status();
307    if (status == axum::http::StatusCode::UNAUTHORIZED
308        || status == axum::http::StatusCode::FORBIDDEN)
309        && !resp
310            .headers()
311            .contains_key(axum::http::header::WWW_AUTHENTICATE)
312    {
313        // 相对路径 —— 客户端按 Host header 拼全 URL;也避免 TLS 终止在前置
314        // 反代(Caddy / Nginx)时我们误把内网地址写进响应头
315        let value = axum::http::HeaderValue::from_static(
316            "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"",
317        );
318        resp.headers_mut()
319            .insert(axum::http::header::WWW_AUTHENTICATE, value);
320    }
321    resp
322}