Skip to main content

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#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct BearerAuthContext {
78    /// Client socket address reported by the HTTP framework.
79    pub client_addr: SocketAddr,
80    /// HTTP route that received the request.
81    pub route: String,
82    /// MCP method or legacy action being authorized.
83    pub action: String,
84}
85
86/// Dynamic bearer-token authorization hook for MCP HTTP transports.
87///
88/// Implement this trait when a server needs revocable tokens, scoped access,
89/// or token lookup from durable storage instead of a single static configured
90/// secret.
91pub trait BearerTokenAuthorizer: Send + Sync {
92    /// Return `true` when a request without a bearer token should be allowed.
93    fn allow_missing_bearer_token(&self, _context: &BearerAuthContext) -> bool {
94        false
95    }
96
97    /// Return `true` when `token` is allowed for `context`.
98    fn authorize_bearer_token(&self, token: &str, context: &BearerAuthContext) -> bool;
99}
100
101/// A running HTTP server instance
102pub struct HttpServerInstance {
103    /// Socket address the server is listening on
104    pub addr: SocketAddr,
105    /// Handle for shutting down the server
106    /// Type erased to allow different framework implementations
107    shutdown_handle: Box<dyn std::any::Any + Send + Sync>,
108}
109
110impl HttpServerInstance {
111    /// Create a new server instance with the given address and shutdown handle
112    pub fn new(addr: SocketAddr, shutdown_handle: Box<dyn std::any::Any + Send + Sync>) -> Self {
113        Self {
114            addr,
115            shutdown_handle,
116        }
117    }
118
119    /// Get the server's socket address
120    pub fn get_addr(&self) -> SocketAddr {
121        self.addr
122    }
123
124    /// Get mutable reference to the shutdown handle for advanced usage
125    pub fn shutdown_handle_mut(&mut self) -> &mut Box<dyn std::any::Any + Send + Sync> {
126        &mut self.shutdown_handle
127    }
128}
129
130/// Trait for HTTP server implementations
131///
132/// Implementations of this trait provide HTTP endpoints for MCP protocols.
133/// Different HTTP frameworks can be swapped by implementing this trait.
134#[async_trait::async_trait]
135pub trait HttpServerAdapter: Send + Sync {
136    /// Start the HTTP server with the given configuration and tool protocol
137    ///
138    /// # Arguments
139    ///
140    /// * `config` - Server configuration (address, auth, IP filtering)
141    /// * `protocol` - The ToolProtocol implementation to expose
142    ///
143    /// # Endpoints
144    ///
145    /// The server must provide the following endpoints:
146    /// - `POST /tools/list` - List all available tools from the protocol
147    /// - `POST /tools/execute` - Execute a tool with given parameters
148    /// - `POST /resources/list` - List all available resources (if protocol supports)
149    /// - `POST /resources/read` - Read a resource by URI (if protocol supports)
150    ///
151    /// # Returns
152    ///
153    /// A running server instance, or an error if startup fails
154    async fn start(
155        &self,
156        config: HttpServerConfig,
157        protocol: Arc<dyn ToolProtocol>,
158    ) -> Result<HttpServerInstance, Box<dyn Error + Send + Sync>>;
159
160    /// Get the name of this adapter (for logging/debugging)
161    fn name(&self) -> &str {
162        "unknown"
163    }
164}
165
166/// Build an Axum router that exposes a [`ToolProtocol`] over the shared HTTP MCP surface.
167///
168/// The returned router serves:
169/// - `POST /tools/list`
170/// - `POST /tools/execute`
171/// - `POST /resources/list`
172/// - `POST /resources/read`
173///
174/// This helper is useful when a crate wants to reuse the shared MCP transport
175/// but still compose extra routes of its own, such as a `/health` endpoint.
176#[cfg(feature = "server")]
177pub fn axum_router(config: &HttpServerConfig, protocol: Arc<dyn ToolProtocol>) -> Router {
178    use crate::events::McpEvent;
179    use axum::{
180        extract::ConnectInfo, http::HeaderMap, http::StatusCode, response::IntoResponse,
181        routing::post, Json, Router,
182    };
183    use serde_json::json;
184    use sha2::{Digest, Sha256};
185    use subtle::ConstantTimeEq;
186
187    fn bearer_from_headers(headers: &HeaderMap) -> Option<&str> {
188        headers
189            .get("Authorization")
190            .and_then(|v| v.to_str().ok())
191            .and_then(|v| v.strip_prefix("Bearer "))
192    }
193
194    /// Validate the Authorization header against static or dynamic bearer auth.
195    ///
196    /// Returns `true` when neither auth mechanism is configured (open server),
197    /// when the supplied `Bearer <token>` matches the static token, or when the
198    /// dynamic authorizer accepts it. Static comparison uses
199    /// `subtle::ConstantTimeEq` on SHA-256 digests so the compiler cannot
200    /// short-circuit the comparison and leak token length via timing.
201    fn check_auth(
202        expected_token: &Option<String>,
203        authorizer: &Option<Arc<dyn BearerTokenAuthorizer>>,
204        headers: &HeaderMap,
205        context: BearerAuthContext,
206    ) -> bool {
207        if expected_token.is_none() && authorizer.is_none() {
208            return true;
209        }
210
211        let Some(provided) = bearer_from_headers(headers) else {
212            return authorizer
213                .as_ref()
214                .is_some_and(|auth| auth.allow_missing_bearer_token(&context));
215        };
216
217        if let Some(expected) = expected_token.as_deref() {
218            let expected_hash = Sha256::digest(expected.as_bytes());
219            let provided_hash = Sha256::digest(provided.as_bytes());
220            if bool::from(expected_hash.ct_eq(&provided_hash)) {
221                return true;
222            }
223        }
224
225        authorizer
226            .as_ref()
227            .is_some_and(|auth| auth.authorize_bearer_token(provided, &context))
228    }
229
230    let bearer_token = Arc::new(config.bearer_token.clone());
231    let bearer_authorizer = Arc::new(config.bearer_authorizer.clone());
232    let ip_filter = Arc::new(config.ip_filter.clone());
233
234    let token_list = bearer_token.clone();
235    let authz_list = bearer_authorizer.clone();
236    let ips_list = ip_filter.clone();
237    let token_exec = bearer_token.clone();
238    let authz_exec = bearer_authorizer.clone();
239    let ips_exec = ip_filter.clone();
240    let token_res_list = bearer_token.clone();
241    let authz_res_list = bearer_authorizer.clone();
242    let ips_res_list = ip_filter.clone();
243    let token_res_read = bearer_token.clone();
244    let authz_res_read = bearer_authorizer.clone();
245    let ips_res_read = ip_filter.clone();
246
247    let eh_list = config.event_handler.clone();
248    let eh_exec = config.event_handler.clone();
249
250    let protocol_list = protocol.clone();
251    let protocol_exec = protocol.clone();
252    let protocol_res_list = protocol.clone();
253    let protocol_res_read = protocol.clone();
254
255    Router::new()
256        .route(
257            "/tools/list",
258            post(
259                move |ConnectInfo(addr): ConnectInfo<SocketAddr>, headers: HeaderMap| {
260                    let token = token_list.clone();
261                    let authz = authz_list.clone();
262                    let allowed = ips_list.clone();
263                    let proto = protocol_list.clone();
264                    let eh = eh_list.clone();
265                    async move {
266                        if !allowed.is_allowed(addr.ip()) {
267                            if let Some(ref handler) = eh {
268                                handler
269                                    .on_mcp_event(&McpEvent::RequestRejected {
270                                        client_addr: addr.ip().to_string(),
271                                        reason: "IP not allowed".to_string(),
272                                    })
273                                    .await;
274                            }
275                            return (
276                                StatusCode::FORBIDDEN,
277                                Json(json!({"error": "Access denied"})),
278                            )
279                                .into_response();
280                        }
281
282                        if !check_auth(
283                            &token,
284                            &authz,
285                            &headers,
286                            BearerAuthContext {
287                                client_addr: addr,
288                                route: "/tools/list".to_string(),
289                                action: "tools/list".to_string(),
290                            },
291                        ) {
292                            return (
293                                StatusCode::UNAUTHORIZED,
294                                Json(json!({"error": "Unauthorized"})),
295                            )
296                                .into_response();
297                        }
298
299                        if let Some(ref handler) = eh {
300                            handler
301                                .on_mcp_event(&McpEvent::ToolListRequested {
302                                    client_addr: addr.ip().to_string(),
303                                })
304                                .await;
305                        }
306
307                        match proto.list_tools().await {
308                            Ok(tools) => {
309                                let tool_count = tools.len();
310                                if let Some(ref handler) = eh {
311                                    handler
312                                        .on_mcp_event(&McpEvent::ToolListReturned {
313                                            client_addr: addr.ip().to_string(),
314                                            tool_count,
315                                        })
316                                        .await;
317                                }
318                                (StatusCode::OK, Json(json!({"tools": tools}))).into_response()
319                            }
320                            Err(e) => (
321                                StatusCode::INTERNAL_SERVER_ERROR,
322                                Json(json!({"error": e.to_string()})),
323                            )
324                                .into_response(),
325                        }
326                    }
327                },
328            ),
329        )
330        .route(
331            "/tools/execute",
332            post(
333                move |ConnectInfo(addr): ConnectInfo<SocketAddr>,
334                      headers: HeaderMap,
335                      Json(payload): Json<serde_json::Value>| {
336                    let token = token_exec.clone();
337                    let authz = authz_exec.clone();
338                    let allowed = ips_exec.clone();
339                    let proto = protocol_exec.clone();
340                    let eh = eh_exec.clone();
341                    async move {
342                        if !allowed.is_allowed(addr.ip()) {
343                            if let Some(ref handler) = eh {
344                                handler
345                                    .on_mcp_event(&McpEvent::RequestRejected {
346                                        client_addr: addr.ip().to_string(),
347                                        reason: "IP not allowed".to_string(),
348                                    })
349                                    .await;
350                            }
351                            return (
352                                StatusCode::FORBIDDEN,
353                                Json(json!({"error": "Access denied"})),
354                            )
355                                .into_response();
356                        }
357
358                        if !check_auth(
359                            &token,
360                            &authz,
361                            &headers,
362                            BearerAuthContext {
363                                client_addr: addr,
364                                route: "/tools/execute".to_string(),
365                                action: "tools/execute".to_string(),
366                            },
367                        ) {
368                            return (
369                                StatusCode::UNAUTHORIZED,
370                                Json(json!({"error": "Unauthorized"})),
371                            )
372                                .into_response();
373                        }
374
375                        let tool_name = payload["tool"].as_str().unwrap_or("").to_string();
376                        let params = payload["parameters"].clone();
377
378                        if let Some(ref handler) = eh {
379                            handler
380                                .on_mcp_event(&McpEvent::ToolCallReceived {
381                                    client_addr: addr.ip().to_string(),
382                                    tool_name: tool_name.clone(),
383                                    parameters: params.clone(),
384                                })
385                                .await;
386                        }
387
388                        let exec_start = std::time::Instant::now();
389                        match proto.execute(&tool_name, params).await {
390                            Ok(result) => {
391                                let duration_ms = exec_start.elapsed().as_millis() as u64;
392                                let success = result.success;
393                                let error = result.error.clone();
394                                if let Some(ref handler) = eh {
395                                    handler
396                                        .on_mcp_event(&McpEvent::ToolCallCompleted {
397                                            client_addr: addr.ip().to_string(),
398                                            tool_name: tool_name.clone(),
399                                            success,
400                                            error,
401                                            duration_ms,
402                                        })
403                                        .await;
404                                }
405                                (StatusCode::OK, Json(json!({"result": result}))).into_response()
406                            }
407                            Err(e) => {
408                                let duration_ms = exec_start.elapsed().as_millis() as u64;
409                                let err_msg = e.to_string();
410                                if let Some(ref handler) = eh {
411                                    handler
412                                        .on_mcp_event(&McpEvent::ToolError {
413                                            source: addr.ip().to_string(),
414                                            tool_name: tool_name.clone(),
415                                            error: err_msg.clone(),
416                                            duration_ms,
417                                        })
418                                        .await;
419                                }
420                                (StatusCode::BAD_REQUEST, Json(json!({"error": err_msg})))
421                                    .into_response()
422                            }
423                        }
424                    }
425                },
426            ),
427        )
428        .route(
429            "/resources/list",
430            post(
431                move |ConnectInfo(addr): ConnectInfo<SocketAddr>, headers: HeaderMap| {
432                    let token = token_res_list.clone();
433                    let authz = authz_res_list.clone();
434                    let allowed = ips_res_list.clone();
435                    let proto = protocol_res_list.clone();
436                    async move {
437                        if !allowed.is_allowed(addr.ip()) {
438                            return (
439                                StatusCode::FORBIDDEN,
440                                Json(json!({"error": "Access denied"})),
441                            )
442                                .into_response();
443                        }
444
445                        if !check_auth(
446                            &token,
447                            &authz,
448                            &headers,
449                            BearerAuthContext {
450                                client_addr: addr,
451                                route: "/resources/list".to_string(),
452                                action: "resources/list".to_string(),
453                            },
454                        ) {
455                            return (
456                                StatusCode::UNAUTHORIZED,
457                                Json(json!({"error": "Unauthorized"})),
458                            )
459                                .into_response();
460                        }
461
462                        if !proto.supports_resources() {
463                            return (
464                                StatusCode::NOT_IMPLEMENTED,
465                                Json(json!({"error": "Resources not supported"})),
466                            )
467                                .into_response();
468                        }
469
470                        match proto.list_resources().await {
471                            Ok(resources) => {
472                                (StatusCode::OK, Json(json!({"resources": resources})))
473                                    .into_response()
474                            }
475                            Err(e) => (
476                                StatusCode::INTERNAL_SERVER_ERROR,
477                                Json(json!({"error": e.to_string()})),
478                            )
479                                .into_response(),
480                        }
481                    }
482                },
483            ),
484        )
485        .route(
486            "/resources/read",
487            post(
488                move |ConnectInfo(addr): ConnectInfo<SocketAddr>,
489                      headers: HeaderMap,
490                      Json(payload): Json<serde_json::Value>| {
491                    let token = token_res_read.clone();
492                    let authz = authz_res_read.clone();
493                    let allowed = ips_res_read.clone();
494                    let proto = protocol_res_read.clone();
495                    async move {
496                        if !allowed.is_allowed(addr.ip()) {
497                            return (
498                                StatusCode::FORBIDDEN,
499                                Json(json!({"error": "Access denied"})),
500                            )
501                                .into_response();
502                        }
503
504                        if !check_auth(
505                            &token,
506                            &authz,
507                            &headers,
508                            BearerAuthContext {
509                                client_addr: addr,
510                                route: "/resources/read".to_string(),
511                                action: "resources/read".to_string(),
512                            },
513                        ) {
514                            return (
515                                StatusCode::UNAUTHORIZED,
516                                Json(json!({"error": "Unauthorized"})),
517                            )
518                                .into_response();
519                        }
520
521                        if !proto.supports_resources() {
522                            return (
523                                StatusCode::NOT_IMPLEMENTED,
524                                Json(json!({"error": "Resources not supported"})),
525                            )
526                                .into_response();
527                        }
528
529                        let uri = payload["uri"].as_str().unwrap_or("");
530
531                        match proto.read_resource(uri).await {
532                            Ok(content) => (
533                                StatusCode::OK,
534                                Json(json!({"uri": uri, "content": content})),
535                            )
536                                .into_response(),
537                            Err(e) => {
538                                (StatusCode::NOT_FOUND, Json(json!({"error": e.to_string()})))
539                                    .into_response()
540                            }
541                        }
542                    }
543                },
544            ),
545        )
546}
547
548/// Default Axum-based HTTP server adapter
549///
550/// Provides a full MCP-compatible HTTP server using the Axum framework.
551/// Only available when the `server` feature is enabled.
552#[cfg(feature = "server")]
553pub struct AxumHttpAdapter;
554
555#[cfg(feature = "server")]
556#[async_trait::async_trait]
557impl HttpServerAdapter for AxumHttpAdapter {
558    async fn start(
559        &self,
560        config: HttpServerConfig,
561        protocol: Arc<dyn ToolProtocol>,
562    ) -> Result<HttpServerInstance, Box<dyn Error + Send + Sync>> {
563        use crate::events::McpEvent;
564        use tokio::net::TcpListener;
565        let app =
566            axum_router(&config, protocol).into_make_service_with_connect_info::<SocketAddr>();
567
568        // Bind and start server
569        let listener = TcpListener::bind(config.addr).await?;
570        let addr = listener.local_addr()?;
571
572        // Fire ServerStarted event
573        if let Some(ref handler) = config.event_handler {
574            handler
575                .on_mcp_event(&McpEvent::ServerStarted {
576                    addr: addr.to_string(),
577                })
578                .await;
579        }
580
581        let server_handle = tokio::spawn(async move { axum::serve(listener, app).await });
582
583        Ok(HttpServerInstance::new(addr, Box::new(server_handle)))
584    }
585
586    fn name(&self) -> &str {
587        "axum"
588    }
589}