1use std::path::PathBuf;
2use std::sync::Arc;
3
4use anyhow::{Context, Result};
5use futu_auth::KeyStore;
6
7use crate::tools;
8
9pub(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 let bind_addr = if listen.starts_with(':') {
44 format!("0.0.0.0{listen}")
45 } else {
46 listen.to_string()
47 };
48
49 let session_manager = std::sync::Arc::new(LocalSessionManager::default());
53 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 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 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 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
160pub(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
211async 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
269pub(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
296pub(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 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}