Skip to main content

tower_mcp/middleware/
tracing.rs

1//! MCP request tracing middleware.
2//!
3//! This module provides [`McpTracingLayer`], a Tower middleware that logs
4//! structured information about MCP requests using the [`tracing`] crate.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use tower_mcp::{McpRouter, StdioTransport};
10//! use tower_mcp::middleware::McpTracingLayer;
11//!
12//! let router = McpRouter::new().server_info("my-server", "1.0.0");
13//!
14//! // Add tracing to all MCP requests
15//! let mut transport = StdioTransport::new(router)
16//!     .layer(McpTracingLayer::new());
17//! ```
18//!
19//! # Logged Information
20//!
21//! For each request, the layer logs:
22//! - Request method (e.g., `tools/call`, `resources/read`)
23//! - Request ID
24//! - Operation-specific details:
25//!   - Tool calls: tool name
26//!   - Resource reads: resource URI
27//!   - Prompt gets: prompt name
28//! - Request duration
29//! - Response status (success or error code)
30//!
31//! # Log Levels
32//!
33//! - `INFO`: Request start and completion
34//! - `DEBUG`: Detailed request/response information
35//! - `WARN`: Error responses
36
37use std::convert::Infallible;
38use std::future::Future;
39use std::pin::Pin;
40use std::task::{Context, Poll};
41use std::time::Instant;
42
43use tower::Layer;
44use tower_service::Service;
45use tracing::{Instrument, Level, Span};
46
47use crate::protocol::McpRequest;
48use crate::router::{RouterRequest, RouterResponse};
49
50/// Tower layer that adds structured tracing to MCP requests.
51///
52/// This layer wraps a service and logs information about each request
53/// using the [`tracing`] crate. It's designed to work with tower-mcp's
54/// `RouterRequest`/`RouterResponse` types.
55///
56/// # Example
57///
58/// ```rust,ignore
59/// use tower_mcp::{McpRouter, StdioTransport};
60/// use tower_mcp::middleware::McpTracingLayer;
61///
62/// let router = McpRouter::new().server_info("my-server", "1.0.0");
63///
64/// // Apply at the transport level for all requests
65/// let mut transport = StdioTransport::new(router)
66///     .layer(McpTracingLayer::new());
67///
68/// // Or apply to specific tools via ToolBuilder
69/// let tool = ToolBuilder::new("search")
70///     .handler(|input: SearchInput| async move { ... })
71///     .layer(McpTracingLayer::new())
72///     .build();
73/// ```
74#[derive(Debug, Clone, Copy)]
75pub struct McpTracingLayer {
76    level: Level,
77}
78
79impl Default for McpTracingLayer {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl McpTracingLayer {
86    /// Create a new tracing layer with default settings (INFO level).
87    pub fn new() -> Self {
88        Self { level: Level::INFO }
89    }
90
91    /// Set the log level for request/response logging.
92    ///
93    /// Default is `INFO`.
94    pub fn level(mut self, level: Level) -> Self {
95        self.level = level;
96        self
97    }
98}
99
100impl<S> Layer<S> for McpTracingLayer {
101    type Service = McpTracingService<S>;
102
103    fn layer(&self, inner: S) -> Self::Service {
104        McpTracingService {
105            inner,
106            level: self.level,
107        }
108    }
109}
110
111/// Tower service that adds tracing to MCP requests.
112///
113/// Created by [`McpTracingLayer`].
114#[derive(Debug, Clone)]
115pub struct McpTracingService<S> {
116    inner: S,
117    level: Level,
118}
119
120impl<S> Service<RouterRequest> for McpTracingService<S>
121where
122    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
123        + Clone
124        + Send
125        + 'static,
126    S::Future: Send,
127{
128    type Response = RouterResponse;
129    type Error = Infallible;
130    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
131
132    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
133        self.inner.poll_ready(cx)
134    }
135
136    fn call(&mut self, req: RouterRequest) -> Self::Future {
137        let method = req.inner.method_name().to_string();
138        let request_id = format!("{:?}", req.id);
139
140        // Extract operation-specific details
141        let (operation_name, operation_target) = extract_operation_details(&req.inner);
142
143        // Create the span based on the configured level
144        let span = create_span(
145            self.level,
146            &method,
147            &request_id,
148            operation_name,
149            operation_target,
150        );
151
152        let start = Instant::now();
153        let fut = self.inner.call(req);
154        let level = self.level;
155
156        Box::pin(
157            async move {
158                let result = fut.await;
159                let duration = start.elapsed();
160
161                match &result {
162                    Ok(response) => {
163                        let duration_ms = duration.as_secs_f64() * 1000.0;
164                        match &response.inner {
165                            Ok(_) => {
166                                log_success(level, &method, duration_ms);
167                            }
168                            Err(err) => {
169                                tracing::warn!(
170                                    method = %method,
171                                    error_code = err.code,
172                                    error_message = %err.message,
173                                    duration_ms = duration_ms,
174                                    "MCP request failed"
175                                );
176                            }
177                        }
178                    }
179                    Err(_) => {
180                        // Infallible, but handle for completeness
181                        tracing::error!(method = %method, "MCP request error (infallible)");
182                    }
183                }
184
185                result
186            }
187            .instrument(span),
188        )
189    }
190}
191
192/// Extract operation-specific name and target from the request.
193pub(crate) fn extract_operation_details(
194    req: &McpRequest,
195) -> (Option<&'static str>, Option<String>) {
196    match req {
197        McpRequest::CallTool(params) => (Some("tool"), Some(params.name.clone())),
198        McpRequest::ReadResource(params) => (Some("resource"), Some(params.uri.clone())),
199        McpRequest::GetPrompt(params) => (Some("prompt"), Some(params.name.clone())),
200        McpRequest::ListTools(_) => (Some("list"), Some("tools".to_string())),
201        McpRequest::ListResources(_) => (Some("list"), Some("resources".to_string())),
202        McpRequest::ListResourceTemplates(_) => {
203            (Some("list"), Some("resource_templates".to_string()))
204        }
205        McpRequest::ListPrompts(_) => (Some("list"), Some("prompts".to_string())),
206        McpRequest::SubscribeResource(params) => (Some("subscribe"), Some(params.uri.clone())),
207        McpRequest::UnsubscribeResource(params) => (Some("unsubscribe"), Some(params.uri.clone())),
208        McpRequest::GetTaskInfo(params) => (Some("task"), Some(params.task_id.clone())),
209        McpRequest::CancelTask(params) => (Some("cancel"), Some(params.task_id.clone())),
210        McpRequest::Complete(params) => {
211            let ref_type = match &params.reference {
212                crate::protocol::CompletionReference::Resource { uri } => {
213                    format!("resource:{}", uri)
214                }
215                crate::protocol::CompletionReference::Prompt { name } => {
216                    format!("prompt:{}", name)
217                }
218                _ => "unknown".to_string(),
219            };
220            (Some("complete"), Some(ref_type))
221        }
222        McpRequest::SetLoggingLevel(params) => {
223            (Some("logging"), Some(format!("{:?}", params.level)))
224        }
225        McpRequest::Initialize(_) => (Some("init"), None),
226        McpRequest::Ping => (Some("ping"), None),
227        McpRequest::Unknown { method, .. } => (Some("unknown"), Some(method.clone())),
228        _ => (Some("unknown"), None),
229    }
230}
231
232/// Create a tracing span with the appropriate level.
233fn create_span(
234    level: Level,
235    method: &str,
236    request_id: &str,
237    operation_name: Option<&str>,
238    operation_target: Option<String>,
239) -> Span {
240    match level {
241        Level::TRACE => tracing::trace_span!(
242            "mcp_request",
243            method = %method,
244            request_id = %request_id,
245            operation = operation_name,
246            target = operation_target.as_deref(),
247        ),
248        Level::DEBUG => tracing::debug_span!(
249            "mcp_request",
250            method = %method,
251            request_id = %request_id,
252            operation = operation_name,
253            target = operation_target.as_deref(),
254        ),
255        Level::INFO => tracing::info_span!(
256            "mcp_request",
257            method = %method,
258            request_id = %request_id,
259            operation = operation_name,
260            target = operation_target.as_deref(),
261        ),
262        Level::WARN => tracing::warn_span!(
263            "mcp_request",
264            method = %method,
265            request_id = %request_id,
266            operation = operation_name,
267            target = operation_target.as_deref(),
268        ),
269        Level::ERROR => tracing::error_span!(
270            "mcp_request",
271            method = %method,
272            request_id = %request_id,
273            operation = operation_name,
274            target = operation_target.as_deref(),
275        ),
276    }
277}
278
279/// Log successful request completion at the configured level.
280fn log_success(level: Level, method: &str, duration_ms: f64) {
281    match level {
282        Level::TRACE => {
283            tracing::trace!(method = %method, duration_ms = duration_ms, "MCP request completed")
284        }
285        Level::DEBUG => {
286            tracing::debug!(method = %method, duration_ms = duration_ms, "MCP request completed")
287        }
288        Level::INFO => {
289            tracing::info!(method = %method, duration_ms = duration_ms, "MCP request completed")
290        }
291        Level::WARN => {
292            tracing::warn!(method = %method, duration_ms = duration_ms, "MCP request completed")
293        }
294        Level::ERROR => {
295            tracing::error!(method = %method, duration_ms = duration_ms, "MCP request completed")
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_layer_creation() {
306        let layer = McpTracingLayer::new();
307        assert_eq!(layer.level, Level::INFO);
308
309        let layer = McpTracingLayer::new().level(Level::DEBUG);
310        assert_eq!(layer.level, Level::DEBUG);
311    }
312
313    #[test]
314    fn test_extract_operation_details() {
315        use crate::protocol::{CallToolParams, GetPromptParams, ReadResourceParams};
316        use serde_json::Value;
317        use std::collections::HashMap;
318
319        // Test tool call
320        let req = McpRequest::CallTool(CallToolParams {
321            input_responses: None,
322            request_state: None,
323            name: "my_tool".to_string(),
324            arguments: Value::Null,
325            meta: None,
326            task: None,
327        });
328        let (name, target) = extract_operation_details(&req);
329        assert_eq!(name, Some("tool"));
330        assert_eq!(target, Some("my_tool".to_string()));
331
332        // Test resource read
333        let req = McpRequest::ReadResource(ReadResourceParams {
334            input_responses: None,
335            request_state: None,
336            uri: "file:///test.txt".to_string(),
337            meta: None,
338        });
339        let (name, target) = extract_operation_details(&req);
340        assert_eq!(name, Some("resource"));
341        assert_eq!(target, Some("file:///test.txt".to_string()));
342
343        // Test prompt get
344        let req = McpRequest::GetPrompt(GetPromptParams {
345            input_responses: None,
346            request_state: None,
347            name: "my_prompt".to_string(),
348            arguments: HashMap::new(),
349            meta: None,
350        });
351        let (name, target) = extract_operation_details(&req);
352        assert_eq!(name, Some("prompt"));
353        assert_eq!(target, Some("my_prompt".to_string()));
354
355        // Test ping
356        let req = McpRequest::Ping;
357        let (name, target) = extract_operation_details(&req);
358        assert_eq!(name, Some("ping"));
359        assert_eq!(target, None);
360    }
361
362    #[test]
363    fn test_extract_operation_details_list_operations() {
364        use crate::protocol::{
365            ListPromptsParams, ListResourceTemplatesParams, ListResourcesParams, ListToolsParams,
366        };
367
368        let (name, target) = extract_operation_details(&McpRequest::ListTools(ListToolsParams {
369            cursor: None,
370            meta: None,
371        }));
372        assert_eq!(name, Some("list"));
373        assert_eq!(target, Some("tools".to_string()));
374
375        let (name, target) =
376            extract_operation_details(&McpRequest::ListResources(ListResourcesParams {
377                cursor: None,
378                meta: None,
379            }));
380        assert_eq!(name, Some("list"));
381        assert_eq!(target, Some("resources".to_string()));
382
383        let (name, target) = extract_operation_details(&McpRequest::ListResourceTemplates(
384            ListResourceTemplatesParams {
385                cursor: None,
386                meta: None,
387            },
388        ));
389        assert_eq!(name, Some("list"));
390        assert_eq!(target, Some("resource_templates".to_string()));
391
392        let (name, target) =
393            extract_operation_details(&McpRequest::ListPrompts(ListPromptsParams {
394                cursor: None,
395                meta: None,
396            }));
397        assert_eq!(name, Some("list"));
398        assert_eq!(target, Some("prompts".to_string()));
399    }
400
401    #[test]
402    fn test_extract_operation_details_initialize() {
403        use crate::protocol::{ClientCapabilities, Implementation, InitializeParams};
404
405        let req = McpRequest::Initialize(InitializeParams {
406            protocol_version: "2025-11-25".to_string(),
407            capabilities: ClientCapabilities::default(),
408            client_info: Implementation {
409                name: "test".to_string(),
410                version: "1.0".to_string(),
411                ..Default::default()
412            },
413            meta: None,
414        });
415        let (name, target) = extract_operation_details(&req);
416        assert_eq!(name, Some("init"));
417        assert_eq!(target, None);
418    }
419
420    #[test]
421    fn test_extract_operation_details_subscribe() {
422        use crate::protocol::SubscribeResourceParams;
423
424        let req = McpRequest::SubscribeResource(SubscribeResourceParams {
425            uri: "file:///watched.txt".to_string(),
426            meta: None,
427        });
428        let (name, target) = extract_operation_details(&req);
429        assert_eq!(name, Some("subscribe"));
430        assert_eq!(target, Some("file:///watched.txt".to_string()));
431    }
432
433    #[test]
434    fn test_extract_operation_details_logging_level() {
435        use crate::protocol::{LogLevel, SetLogLevelParams};
436
437        let req = McpRequest::SetLoggingLevel(SetLogLevelParams {
438            level: LogLevel::Debug,
439            meta: None,
440        });
441        let (name, target) = extract_operation_details(&req);
442        assert_eq!(name, Some("logging"));
443        assert!(target.is_some());
444    }
445
446    #[test]
447    fn test_extract_operation_details_completion() {
448        use crate::protocol::{CompleteParams, CompletionArgument, CompletionReference};
449
450        let req = McpRequest::Complete(CompleteParams {
451            reference: CompletionReference::Prompt {
452                name: "my-prompt".to_string(),
453            },
454            argument: CompletionArgument::new("arg1", "val"),
455            context: None,
456            meta: None,
457        });
458        let (name, target) = extract_operation_details(&req);
459        assert_eq!(name, Some("complete"));
460        assert_eq!(target, Some("prompt:my-prompt".to_string()));
461
462        let req = McpRequest::Complete(CompleteParams {
463            reference: CompletionReference::Resource {
464                uri: "file:///test".to_string(),
465            },
466            argument: CompletionArgument::new("arg1", "val"),
467            context: None,
468            meta: None,
469        });
470        let (_, target) = extract_operation_details(&req);
471        assert_eq!(target, Some("resource:file:///test".to_string()));
472    }
473
474    #[test]
475    fn test_extract_operation_details_unknown_method() {
476        let req = McpRequest::Unknown {
477            method: "custom/method".to_string(),
478            params: None,
479        };
480        let (name, target) = extract_operation_details(&req);
481        assert_eq!(name, Some("unknown"));
482        assert_eq!(target, Some("custom/method".to_string()));
483    }
484
485    #[test]
486    fn test_layer_level_configuration() {
487        let layer = McpTracingLayer::new().level(Level::TRACE);
488        assert_eq!(layer.level, Level::TRACE);
489
490        let layer = McpTracingLayer::new().level(Level::ERROR);
491        assert_eq!(layer.level, Level::ERROR);
492
493        let layer = McpTracingLayer::new().level(Level::WARN);
494        assert_eq!(layer.level, Level::WARN);
495    }
496}