|
| 1 | +# Observability |
| 2 | + |
| 3 | +MCP applications often need traces, metrics, or structured logs around tool, |
| 4 | +resource, and prompt activity. Transport middleware is useful for HTTP-level |
| 5 | +events, but MCP primitive activity is best observed at the MCP request layer |
| 6 | +where the protocol method and request parameters are still visible. |
| 7 | + |
| 8 | +## Where to Instrument |
| 9 | + |
| 10 | +Use the narrowest layer that has the data you need: |
| 11 | + |
| 12 | +| Layer | Use for | Notes | |
| 13 | +| --- | --- | --- | |
| 14 | +| ASGI middleware | HTTP status codes, headers, auth, reverse-proxy behavior | This sees transport requests, not every MCP primitive operation. Streamable HTTP and SSE can multiplex multiple MCP messages through a long-lived transport. | |
| 15 | +| `Server.middleware` | Server-side MCP requests and notifications | Wraps `initialize`, unknown methods, validation failures, and registered handlers. This is the usual place for server spans around `tools/call`, `resources/read`, and `prompts/get`. | |
| 16 | +| Client wrapper code | Client-side outgoing MCP requests | Wrap calls such as `client.call_tool()`, `client.read_resource()`, or `client.get_prompt()` when you want the caller-side span or metric. | |
| 17 | +| Handler code | Domain-specific work inside a tool, resource, or prompt | Use this for application details such as database queries, external API calls, cache hits, or business identifiers. | |
| 18 | + |
| 19 | +## Server-Side Middleware |
| 20 | + |
| 21 | +`Server.middleware` runs around every inbound MCP request before params are |
| 22 | +validated and before the registered handler is invoked. A middleware can record |
| 23 | +duration, success or failure, the protocol method, and a safe target name. |
| 24 | + |
| 25 | +```python title="server_observability.py" |
| 26 | +import time |
| 27 | +from collections.abc import Mapping |
| 28 | +from typing import Any |
| 29 | + |
| 30 | +from mcp.server import Server, ServerRequestContext |
| 31 | +from mcp.server.context import CallNext, HandlerResult |
| 32 | + |
| 33 | + |
| 34 | +async def observe_mcp_request( |
| 35 | + ctx: ServerRequestContext[Any, Any], |
| 36 | + method: str, |
| 37 | + params: Mapping[str, Any] | None, |
| 38 | + call_next: CallNext, |
| 39 | +) -> HandlerResult: |
| 40 | + started = time.perf_counter() |
| 41 | + target = params.get("name") if isinstance(params, Mapping) else None |
| 42 | + |
| 43 | + try: |
| 44 | + result = await call_next() |
| 45 | + except Exception: |
| 46 | + duration_ms = (time.perf_counter() - started) * 1000 |
| 47 | + print( |
| 48 | + "mcp.request failed", |
| 49 | + { |
| 50 | + "method": method, |
| 51 | + "target": target, |
| 52 | + "request_id": ctx.request_id, |
| 53 | + "duration_ms": round(duration_ms, 2), |
| 54 | + }, |
| 55 | + ) |
| 56 | + raise |
| 57 | + |
| 58 | + duration_ms = (time.perf_counter() - started) * 1000 |
| 59 | + print( |
| 60 | + "mcp.request completed", |
| 61 | + { |
| 62 | + "method": method, |
| 63 | + "target": target, |
| 64 | + "request_id": ctx.request_id, |
| 65 | + "duration_ms": round(duration_ms, 2), |
| 66 | + }, |
| 67 | + ) |
| 68 | + return result |
| 69 | + |
| 70 | + |
| 71 | +server = Server("observed-server", on_call_tool=...) |
| 72 | +server.middleware.append(observe_mcp_request) |
| 73 | +``` |
| 74 | + |
| 75 | +For OpenTelemetry, the same pattern can create a span around `await call_next()` |
| 76 | +instead of printing. Keep exported attributes small and safe: method name, |
| 77 | +request id, status, duration, and the prompt/resource/tool name are usually |
| 78 | +enough. Avoid recording tool arguments, resource contents, prompt text, tokens, |
| 79 | +or authentication data unless your application has explicitly classified them |
| 80 | +as safe to export. |
| 81 | + |
| 82 | +## Primitive Span Shape |
| 83 | + |
| 84 | +A practical span and metric shape is: |
| 85 | + |
| 86 | +| MCP method | Suggested span name | Useful attributes | |
| 87 | +| --- | --- | --- | |
| 88 | +| `tools/call` | `MCP tools/call <name>` | `mcp.method.name`, `mcp.tool.name`, `jsonrpc.request.id`, status | |
| 89 | +| `resources/read` | `MCP resources/read <uri-template-or-scheme>` | `mcp.method.name`, a low-cardinality resource identifier, `jsonrpc.request.id`, status | |
| 90 | +| `prompts/get` | `MCP prompts/get <name>` | `mcp.method.name`, `mcp.prompt.name`, `jsonrpc.request.id`, status | |
| 91 | +| `*/list` | `MCP <method>` | `mcp.method.name`, result count when safe | |
| 92 | + |
| 93 | +Prefer low-cardinality attributes. For example, use a resource scheme or |
| 94 | +template name instead of the full resource URI if the URI may contain document |
| 95 | +ids, user ids, or file paths. |
| 96 | + |
| 97 | +## Request Tracing vs Primitive Tracing |
| 98 | + |
| 99 | +Request-level tracing answers "which MCP message was handled?" Primitive-level |
| 100 | +tracing answers "which tool, resource, or prompt did the application execute?" |
| 101 | +Most production systems need both: |
| 102 | + |
| 103 | +1. A request span around the MCP method, created in middleware. |
| 104 | +2. Optional child spans inside handlers for application work such as model |
| 105 | + calls, database queries, network calls, or filesystem operations. |
| 106 | + |
| 107 | +Do not rely only on HTTP middleware for primitive tracing. With streamable HTTP |
| 108 | +or SSE, HTTP request boundaries do not always line up with MCP method |
| 109 | +boundaries, and headers may only be present on the transport request rather |
| 110 | +than each MCP message. |
| 111 | + |
| 112 | +## Client-Side Calls |
| 113 | + |
| 114 | +Client applications can use the same naming scheme around outgoing SDK calls: |
| 115 | + |
| 116 | +```python title="client_observability.py" |
| 117 | +import time |
| 118 | + |
| 119 | + |
| 120 | +async def observed_call_tool(client, name: str, arguments: dict): |
| 121 | + started = time.perf_counter() |
| 122 | + try: |
| 123 | + return await client.call_tool(name, arguments) |
| 124 | + finally: |
| 125 | + duration_ms = (time.perf_counter() - started) * 1000 |
| 126 | + print( |
| 127 | + "mcp.client.call_tool", |
| 128 | + {"tool": name, "duration_ms": round(duration_ms, 2)}, |
| 129 | + ) |
| 130 | +``` |
| 131 | + |
| 132 | +If you propagate trace context between client and server, put it in the MCP |
| 133 | +request metadata rather than assuming transport headers will be available for |
| 134 | +each logical request. |
| 135 | + |
0 commit comments