mcp/http.rs
1//! HTTP Server Adapter for MCP
2//!
3//! This module defines a pluggable interface for HTTP servers that expose MCP protocols.
4//! The adapter pattern allows different HTTP frameworks (axum, actix, warp, etc.) to be
5//! swapped without changing the MCPServerBuilder API.
6//!
7//! # Design
8//!
9//! ```text
10//! MCPServerBuilder
11//! ↓
12//! (configures)
13//! ↓
14//! HttpServerAdapter (trait)
15//! ↓ (implements)
16//! ┌────┴────┬─────────────┐
17//! ↓ ↓ ↓
18//! AxumAdapter ActixAdapter OtherAdapter
19//! ```
20//!
21//! This allows users to swap HTTP frameworks without changing their code.
22
23use crate::builder_utils::IpFilter;
24use crate::events::McpEventHandler;
25use crate::protocol::ToolProtocol;
26use std::error::Error;
27use std::net::SocketAddr;
28use std::sync::Arc;
29
30#[cfg(feature = "server")]
31use axum::Router;
32
33/// Configuration for an HTTP MCP server
34pub struct HttpServerConfig {
35 /// Socket address to bind to (e.g., "127.0.0.1:8080")
36 pub addr: SocketAddr,
37 /// Optional bearer token for authentication
38 pub bearer_token: Option<String>,
39 /// Optional dynamic bearer-token authorizer.
40 ///
41 /// When present, this authorizer is consulted after the static
42 /// [`bearer_token`](Self::bearer_token) check. A request is accepted when
43 /// either configured mechanism accepts the supplied bearer token.
44 pub bearer_authorizer: Option<Arc<dyn BearerTokenAuthorizer>>,
45 /// IP filter controlling which client addresses are allowed
46 pub ip_filter: IpFilter,
47 /// Optional event handler for MCP server lifecycle and request events
48 pub event_handler: Option<Arc<dyn McpEventHandler>>,
49}
50
51impl Clone for HttpServerConfig {
52 fn clone(&self) -> Self {
53 Self {
54 addr: self.addr,
55 bearer_token: self.bearer_token.clone(),
56 bearer_authorizer: self.bearer_authorizer.clone(),
57 ip_filter: self.ip_filter.clone(),
58 event_handler: self.event_handler.clone(),
59 }
60 }
61}
62
63impl std::fmt::Debug for HttpServerConfig {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("HttpServerConfig")
66 .field("addr", &self.addr)
67 .field("has_bearer_token", &self.bearer_token.is_some())
68 .field("has_bearer_authorizer", &self.bearer_authorizer.is_some())
69 .field("ip_filter", &self.ip_filter)
70 .field("has_event_handler", &self.event_handler.is_some())
71 .finish()
72 }
73}
74
75/// Per-request context passed to a dynamic bearer-token authorizer.
76///
77/// The MCP runtime builds this value before dispatching a request to the
78/// protocol implementation. Servers can inspect it to make authorization
79/// decisions that depend on more than the raw token string.
80///
81/// # Payload shape
82///
83/// `payload` is deliberately transport-shaped:
84///
85/// - streamable HTTP JSON-RPC requests receive the `params` object, such as
86/// `{ "name": "tool_name", "arguments": { ... } }` for `tools/call`.
87/// - legacy `/tools/execute` requests receive the full request body, usually
88/// `{ "tool": "tool_name", "parameters": { ... } }`.
89/// - metadata-style routes such as `/tools/list` and `/resources/list` receive
90/// `None` when there is no useful body to authorize.
91///
92/// Server crates keep ownership of policy. For example, a memory server can
93/// inspect tool arguments inside `payload` and allow a token for one chain while
94/// denying the same token for another.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct BearerAuthContext {
97 /// Client socket address reported by the HTTP framework.
98 pub client_addr: SocketAddr,
99 /// HTTP route that received the request.
100 pub route: String,
101 /// MCP method or legacy action being authorized.
102 pub action: String,
103 /// Parsed request payload or JSON-RPC params, when available.
104 ///
105 /// This value is cloned from the already-parsed request body so authorizers
106 /// do not need to parse JSON a second time.
107 pub payload: Option<serde_json::Value>,
108}
109
110/// Dynamic bearer-token authorization hook for MCP HTTP transports.
111///
112/// Implement this trait when a server needs revocable tokens, scoped access,
113/// or token lookup from durable storage instead of a single static configured
114/// secret.
115///
116/// # Examples
117///
118/// ```
119/// use mcp::{BearerAuthContext, BearerTokenAuthorizer};
120///
121/// struct ToolListOnly;
122///
123/// impl BearerTokenAuthorizer for ToolListOnly {
124/// fn authorize_bearer_token(&self, token: &str, context: &BearerAuthContext) -> bool {
125/// token == "good-token" && context.action == "tools/list"
126/// }
127/// }
128/// ```
129pub trait BearerTokenAuthorizer: Send + Sync {
130 /// Return `true` when a request without a bearer token should be allowed.
131 ///
132 /// The default is fail-closed. Override this only when the embedding server
133 /// has an explicit runtime mode where unauthenticated requests are expected.
134 fn allow_missing_bearer_token(&self, _context: &BearerAuthContext) -> bool {
135 false
136 }
137
138 /// Return `true` when `token` is allowed for `context`.
139 ///
140 /// Implementations should avoid logging raw token values. If comparing
141 /// against stored secrets, prefer constant-time hash comparison in the
142 /// server crate.
143 fn authorize_bearer_token(&self, token: &str, context: &BearerAuthContext) -> bool;
144}
145
146/// A running HTTP server instance
147pub struct HttpServerInstance {
148 /// Socket address the server is listening on
149 pub addr: SocketAddr,
150 /// Handle for shutting down the server
151 /// Type erased to allow different framework implementations
152 shutdown_handle: Box<dyn std::any::Any + Send + Sync>,
153}
154
155impl HttpServerInstance {
156 /// Create a new server instance with the given address and shutdown handle
157 pub fn new(addr: SocketAddr, shutdown_handle: Box<dyn std::any::Any + Send + Sync>) -> Self {
158 Self {
159 addr,
160 shutdown_handle,
161 }
162 }
163
164 /// Get the server's socket address
165 pub fn get_addr(&self) -> SocketAddr {
166 self.addr
167 }
168
169 /// Get mutable reference to the shutdown handle for advanced usage
170 pub fn shutdown_handle_mut(&mut self) -> &mut Box<dyn std::any::Any + Send + Sync> {
171 &mut self.shutdown_handle
172 }
173}
174
175/// Trait for HTTP server implementations
176///
177/// Implementations of this trait provide HTTP endpoints for MCP protocols.
178/// Different HTTP frameworks can be swapped by implementing this trait.
179#[async_trait::async_trait]
180pub trait HttpServerAdapter: Send + Sync {
181 /// Start the HTTP server with the given configuration and tool protocol
182 ///
183 /// # Arguments
184 ///
185 /// * `config` - Server configuration (address, auth, IP filtering)
186 /// * `protocol` - The ToolProtocol implementation to expose
187 ///
188 /// # Endpoints
189 ///
190 /// The server must provide the following endpoints:
191 /// - `POST /tools/list` - List all available tools from the protocol
192 /// - `POST /tools/execute` - Execute a tool with given parameters
193 /// - `POST /resources/list` - List all available resources (if protocol supports)
194 /// - `POST /resources/read` - Read a resource by URI (if protocol supports)
195 ///
196 /// # Returns
197 ///
198 /// A running server instance, or an error if startup fails
199 async fn start(
200 &self,
201 config: HttpServerConfig,
202 protocol: Arc<dyn ToolProtocol>,
203 ) -> Result<HttpServerInstance, Box<dyn Error + Send + Sync>>;
204
205 /// Get the name of this adapter (for logging/debugging)
206 fn name(&self) -> &str {
207 "unknown"
208 }
209}
210
211/// Build an Axum router that exposes a [`ToolProtocol`] over the shared HTTP MCP surface.
212///
213/// The returned router serves:
214/// - `POST /tools/list`
215/// - `POST /tools/execute`
216/// - `POST /resources/list`
217/// - `POST /resources/read`
218///
219/// This helper is useful when a crate wants to reuse the shared MCP transport
220/// but still compose extra routes of its own, such as a `/health` endpoint.
221#[cfg(feature = "server")]
222pub fn axum_router(config: &HttpServerConfig, protocol: Arc<dyn ToolProtocol>) -> Router {
223 use crate::events::McpEvent;
224 use axum::{
225 extract::ConnectInfo, http::HeaderMap, http::StatusCode, response::IntoResponse,
226 routing::post, Json, Router,
227 };
228 use serde_json::json;
229 use sha2::{Digest, Sha256};
230 use subtle::ConstantTimeEq;
231
232 fn bearer_from_headers(headers: &HeaderMap) -> Option<&str> {
233 headers
234 .get("Authorization")
235 .and_then(|v| v.to_str().ok())
236 .and_then(|v| v.strip_prefix("Bearer "))
237 }
238
239 /// Validate the Authorization header against static or dynamic bearer auth.
240 ///
241 /// Returns `true` when neither auth mechanism is configured (open server),
242 /// when the supplied `Bearer <token>` matches the static token, or when the
243 /// dynamic authorizer accepts it. Static comparison uses
244 /// `subtle::ConstantTimeEq` on SHA-256 digests so the compiler cannot
245 /// short-circuit the comparison and leak token length via timing.
246 fn check_auth(
247 expected_token: &Option<String>,
248 authorizer: &Option<Arc<dyn BearerTokenAuthorizer>>,
249 headers: &HeaderMap,
250 context: BearerAuthContext,
251 ) -> bool {
252 if expected_token.is_none() && authorizer.is_none() {
253 return true;
254 }
255
256 let Some(provided) = bearer_from_headers(headers) else {
257 return authorizer
258 .as_ref()
259 .is_some_and(|auth| auth.allow_missing_bearer_token(&context));
260 };
261
262 if let Some(expected) = expected_token.as_deref() {
263 let expected_hash = Sha256::digest(expected.as_bytes());
264 let provided_hash = Sha256::digest(provided.as_bytes());
265 if bool::from(expected_hash.ct_eq(&provided_hash)) {
266 return true;
267 }
268 }
269
270 authorizer
271 .as_ref()
272 .is_some_and(|auth| auth.authorize_bearer_token(provided, &context))
273 }
274
275 let bearer_token = Arc::new(config.bearer_token.clone());
276 let bearer_authorizer = Arc::new(config.bearer_authorizer.clone());
277 let ip_filter = Arc::new(config.ip_filter.clone());
278
279 let token_list = bearer_token.clone();
280 let authz_list = bearer_authorizer.clone();
281 let ips_list = ip_filter.clone();
282 let token_exec = bearer_token.clone();
283 let authz_exec = bearer_authorizer.clone();
284 let ips_exec = ip_filter.clone();
285 let token_res_list = bearer_token.clone();
286 let authz_res_list = bearer_authorizer.clone();
287 let ips_res_list = ip_filter.clone();
288 let token_res_read = bearer_token.clone();
289 let authz_res_read = bearer_authorizer.clone();
290 let ips_res_read = ip_filter.clone();
291
292 let eh_list = config.event_handler.clone();
293 let eh_exec = config.event_handler.clone();
294
295 let protocol_list = protocol.clone();
296 let protocol_exec = protocol.clone();
297 let protocol_res_list = protocol.clone();
298 let protocol_res_read = protocol.clone();
299
300 Router::new()
301 .route(
302 "/tools/list",
303 post(
304 move |ConnectInfo(addr): ConnectInfo<SocketAddr>, headers: HeaderMap| {
305 let token = token_list.clone();
306 let authz = authz_list.clone();
307 let allowed = ips_list.clone();
308 let proto = protocol_list.clone();
309 let eh = eh_list.clone();
310 async move {
311 if !allowed.is_allowed(addr.ip()) {
312 if let Some(ref handler) = eh {
313 handler
314 .on_mcp_event(&McpEvent::RequestRejected {
315 client_addr: addr.ip().to_string(),
316 reason: "IP not allowed".to_string(),
317 })
318 .await;
319 }
320 return (
321 StatusCode::FORBIDDEN,
322 Json(json!({"error": "Access denied"})),
323 )
324 .into_response();
325 }
326
327 if !check_auth(
328 &token,
329 &authz,
330 &headers,
331 BearerAuthContext {
332 client_addr: addr,
333 route: "/tools/list".to_string(),
334 action: "tools/list".to_string(),
335 payload: None,
336 },
337 ) {
338 return (
339 StatusCode::UNAUTHORIZED,
340 Json(json!({"error": "Unauthorized"})),
341 )
342 .into_response();
343 }
344
345 if let Some(ref handler) = eh {
346 handler
347 .on_mcp_event(&McpEvent::ToolListRequested {
348 client_addr: addr.ip().to_string(),
349 })
350 .await;
351 }
352
353 match proto.list_tools().await {
354 Ok(tools) => {
355 let tool_count = tools.len();
356 if let Some(ref handler) = eh {
357 handler
358 .on_mcp_event(&McpEvent::ToolListReturned {
359 client_addr: addr.ip().to_string(),
360 tool_count,
361 })
362 .await;
363 }
364 (StatusCode::OK, Json(json!({"tools": tools}))).into_response()
365 }
366 Err(e) => (
367 StatusCode::INTERNAL_SERVER_ERROR,
368 Json(json!({"error": e.to_string()})),
369 )
370 .into_response(),
371 }
372 }
373 },
374 ),
375 )
376 .route(
377 "/tools/execute",
378 post(
379 move |ConnectInfo(addr): ConnectInfo<SocketAddr>,
380 headers: HeaderMap,
381 Json(payload): Json<serde_json::Value>| {
382 let token = token_exec.clone();
383 let authz = authz_exec.clone();
384 let allowed = ips_exec.clone();
385 let proto = protocol_exec.clone();
386 let eh = eh_exec.clone();
387 async move {
388 if !allowed.is_allowed(addr.ip()) {
389 if let Some(ref handler) = eh {
390 handler
391 .on_mcp_event(&McpEvent::RequestRejected {
392 client_addr: addr.ip().to_string(),
393 reason: "IP not allowed".to_string(),
394 })
395 .await;
396 }
397 return (
398 StatusCode::FORBIDDEN,
399 Json(json!({"error": "Access denied"})),
400 )
401 .into_response();
402 }
403
404 if !check_auth(
405 &token,
406 &authz,
407 &headers,
408 BearerAuthContext {
409 client_addr: addr,
410 route: "/tools/execute".to_string(),
411 action: "tools/execute".to_string(),
412 payload: Some(payload.clone()),
413 },
414 ) {
415 return (
416 StatusCode::UNAUTHORIZED,
417 Json(json!({"error": "Unauthorized"})),
418 )
419 .into_response();
420 }
421
422 let tool_name = payload["tool"].as_str().unwrap_or("").to_string();
423 let params = payload["parameters"].clone();
424
425 if let Some(ref handler) = eh {
426 handler
427 .on_mcp_event(&McpEvent::ToolCallReceived {
428 client_addr: addr.ip().to_string(),
429 tool_name: tool_name.clone(),
430 parameters: params.clone(),
431 })
432 .await;
433 }
434
435 let exec_start = std::time::Instant::now();
436 match proto.execute(&tool_name, params).await {
437 Ok(result) => {
438 let duration_ms = exec_start.elapsed().as_millis() as u64;
439 let success = result.success;
440 let error = result.error.clone();
441 if let Some(ref handler) = eh {
442 handler
443 .on_mcp_event(&McpEvent::ToolCallCompleted {
444 client_addr: addr.ip().to_string(),
445 tool_name: tool_name.clone(),
446 success,
447 error,
448 duration_ms,
449 })
450 .await;
451 }
452 (StatusCode::OK, Json(json!({"result": result}))).into_response()
453 }
454 Err(e) => {
455 let duration_ms = exec_start.elapsed().as_millis() as u64;
456 let err_msg = e.to_string();
457 if let Some(ref handler) = eh {
458 handler
459 .on_mcp_event(&McpEvent::ToolError {
460 source: addr.ip().to_string(),
461 tool_name: tool_name.clone(),
462 error: err_msg.clone(),
463 duration_ms,
464 })
465 .await;
466 }
467 (StatusCode::BAD_REQUEST, Json(json!({"error": err_msg})))
468 .into_response()
469 }
470 }
471 }
472 },
473 ),
474 )
475 .route(
476 "/resources/list",
477 post(
478 move |ConnectInfo(addr): ConnectInfo<SocketAddr>, headers: HeaderMap| {
479 let token = token_res_list.clone();
480 let authz = authz_res_list.clone();
481 let allowed = ips_res_list.clone();
482 let proto = protocol_res_list.clone();
483 async move {
484 if !allowed.is_allowed(addr.ip()) {
485 return (
486 StatusCode::FORBIDDEN,
487 Json(json!({"error": "Access denied"})),
488 )
489 .into_response();
490 }
491
492 if !check_auth(
493 &token,
494 &authz,
495 &headers,
496 BearerAuthContext {
497 client_addr: addr,
498 route: "/resources/list".to_string(),
499 action: "resources/list".to_string(),
500 payload: None,
501 },
502 ) {
503 return (
504 StatusCode::UNAUTHORIZED,
505 Json(json!({"error": "Unauthorized"})),
506 )
507 .into_response();
508 }
509
510 if !proto.supports_resources() {
511 return (
512 StatusCode::NOT_IMPLEMENTED,
513 Json(json!({"error": "Resources not supported"})),
514 )
515 .into_response();
516 }
517
518 match proto.list_resources().await {
519 Ok(resources) => {
520 (StatusCode::OK, Json(json!({"resources": resources})))
521 .into_response()
522 }
523 Err(e) => (
524 StatusCode::INTERNAL_SERVER_ERROR,
525 Json(json!({"error": e.to_string()})),
526 )
527 .into_response(),
528 }
529 }
530 },
531 ),
532 )
533 .route(
534 "/resources/read",
535 post(
536 move |ConnectInfo(addr): ConnectInfo<SocketAddr>,
537 headers: HeaderMap,
538 Json(payload): Json<serde_json::Value>| {
539 let token = token_res_read.clone();
540 let authz = authz_res_read.clone();
541 let allowed = ips_res_read.clone();
542 let proto = protocol_res_read.clone();
543 async move {
544 if !allowed.is_allowed(addr.ip()) {
545 return (
546 StatusCode::FORBIDDEN,
547 Json(json!({"error": "Access denied"})),
548 )
549 .into_response();
550 }
551
552 if !check_auth(
553 &token,
554 &authz,
555 &headers,
556 BearerAuthContext {
557 client_addr: addr,
558 route: "/resources/read".to_string(),
559 action: "resources/read".to_string(),
560 payload: Some(payload.clone()),
561 },
562 ) {
563 return (
564 StatusCode::UNAUTHORIZED,
565 Json(json!({"error": "Unauthorized"})),
566 )
567 .into_response();
568 }
569
570 if !proto.supports_resources() {
571 return (
572 StatusCode::NOT_IMPLEMENTED,
573 Json(json!({"error": "Resources not supported"})),
574 )
575 .into_response();
576 }
577
578 let uri = payload["uri"].as_str().unwrap_or("");
579
580 match proto.read_resource(uri).await {
581 Ok(content) => (
582 StatusCode::OK,
583 Json(json!({"uri": uri, "content": content})),
584 )
585 .into_response(),
586 Err(e) => {
587 (StatusCode::NOT_FOUND, Json(json!({"error": e.to_string()})))
588 .into_response()
589 }
590 }
591 }
592 },
593 ),
594 )
595}
596
597/// Default Axum-based HTTP server adapter
598///
599/// Provides a full MCP-compatible HTTP server using the Axum framework.
600/// Only available when the `server` feature is enabled.
601#[cfg(feature = "server")]
602pub struct AxumHttpAdapter;
603
604#[cfg(feature = "server")]
605#[async_trait::async_trait]
606impl HttpServerAdapter for AxumHttpAdapter {
607 async fn start(
608 &self,
609 config: HttpServerConfig,
610 protocol: Arc<dyn ToolProtocol>,
611 ) -> Result<HttpServerInstance, Box<dyn Error + Send + Sync>> {
612 use crate::events::McpEvent;
613 use tokio::net::TcpListener;
614 let app =
615 axum_router(&config, protocol).into_make_service_with_connect_info::<SocketAddr>();
616
617 // Bind and start server
618 let listener = TcpListener::bind(config.addr).await?;
619 let addr = listener.local_addr()?;
620
621 // Fire ServerStarted event
622 if let Some(ref handler) = config.event_handler {
623 handler
624 .on_mcp_event(&McpEvent::ServerStarted {
625 addr: addr.to_string(),
626 })
627 .await;
628 }
629
630 let server_handle = tokio::spawn(async move { axum::serve(listener, app).await });
631
632 Ok(HttpServerInstance::new(addr, Box::new(server_handle)))
633 }
634
635 fn name(&self) -> &str {
636 "axum"
637 }
638}