feat(@modelcontextprotocol/sdk): add tracing for MCP server requests and client operations#8916
Conversation
…._onrequest and McpServer.executeToolHandler - Add orchestrion rewriter entries for Protocol._onrequest (Sync) and McpServer.executeToolHandler (Async) in both CJS (dist/cjs) and ESM (dist/esm) paths - Add McpServerRequestPlugin: creates mcp.server.request span for every incoming JSON-RPC request, extracting trace context from HTTP transport headers when available - Add McpServerToolCallPlugin: creates mcp.server.tool.call child span for tool executions, marking isError results as errors - Fix span lifecycle: use end() (not finish()) for Sync orchestrion hooks, which emit start/end events rather than start/finish - Add server-side test assertions for both new span types across two SDK versions Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Overall package sizeSelf size: 6.72 MB Dependency sizes| name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.3.1 | 122.62 kB | 438.86 kB | | opentracing | 0.14.7 | 194.81 kB | 194.81 kB | | dc-polyfill | 0.1.11 | 25.74 kB | 25.74 kB |🤖 This report was automatically generated by heaviest-objects-in-the-universe |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #8916 +/- ##
==========================================
+ Coverage 96.57% 96.58% +0.01%
==========================================
Files 918 919 +1
Lines 121521 121955 +434
Branches 21089 21035 -54
==========================================
+ Hits 117355 117796 +441
+ Misses 4166 4159 -7
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 87f73a07a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…cycle comment, and add context propagation test - McpServerToolCallPlugin: RegisteredTool objects have no .name property — the name is the key in McpServer._registeredTools. Look up by reference via ctx.self so mcp.server.tool.call spans now have the correct resource tag (was undefined). - McpServerRequestPlugin: document why kind:'Sync'/end() is intentional. Protocol._onrequest is fire-and-forget (returns void, schedules async work internally). Async kind would await undefined and resolve immediately — same lifecycle problem. The span represents "request received" not "request completed"; mcp.server.tool.call on executeToolHandler covers the meaningful duration. Add matching comment to the rewriter config entry. - setIsErrorTag: extract shared module-level helper used by both McpToolCallPlugin and McpServerToolCallPlugin (no functional change; already deduplicated). - Tests: add context propagation test asserting mcp.server.request is a child of mcp.client.tool.call when using InMemoryTransport (same process, async context propagates naturally). Use single assertSomeTraces callback so both span IDs are captured from the same trace payload. Also assert resource:'test-tool' on the mcp.server.tool.call happy path test to cover the name-extraction fix. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: c469830 | Docs | Datadog PR Page | Give us feedback! |
BenchmarksBenchmark execution time: 2026-07-13 14:37:15 Comparing candidate commit c469830 in PR branch Found 0 performance improvements and 0 performance regressions! Performance is the same for 2310 metrics, 48 unstable metrics.
|
…for full-lifecycle server request spans Protocol._onrequest is fire-and-forget: it returns void and schedules all async work via an internal Promise chain. Using kind:'Sync' ended the span immediately on return, before any handler ran; kind:'Async' was equally wrong (awaiting undefined resolves instantly). Replace with shimmer.wrap on Protocol.prototype._onrequest: - Publish apm:mcp:server:request:start via runStores to establish async context for the full Promise chain (enabling correct parent-child relationships) - Intercept _requestHandlerAbortControllers.delete (called in .finally()) to publish apm:mcp:server:request:finish when the chain fully completes Update McpServerRequestPlugin to subscribe to the new channels instead of the orchestrion prefix, and use the finish event to close the span. Remove the four _onrequest entries (ESM+CJS) from the orchestrion rewriter config. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…, trim comments Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…t hook Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…s no root default export Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1dee468 to
94d9ded
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94d9dedd17
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…d and transport disconnect Two bugs identified by Codex review: 1. When the SDK has no registered handler for a method, _onrequest() sends MethodNotFound and returns without creating an AbortController. Previously, the pending context was recorded before calling original(), so those spans leaked. Fix: after calling original(), check whether the SDK registered a controller for this request.id — if not, publish finish immediately. 2. When a transport disconnects with in-flight requests, _onclose() calls _requestHandlerAbortControllers.clear(), bypassing the wrapped delete(). Fix: also wrap clear() on the map to publish finish for all pending contexts. Tests added for both paths. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…hod-specific tags Add resource/name/uri/args/response tags to mcp.server.request spans: - tools/call: mcp.tool.name, mcp.request.arguments, mcp.tool.response - tools/list: mcp.tool.names - resources/read: mcp.resource.uri - resources/list: mcp.resource.uris - prompts/get: mcp.prompt.name, mcp.request.arguments - prompts/list: mcp.prompt.names, mcp.prompt.descriptions Replace INTERNAL_METHODS denylist with an allowlist regex (^(?:tools|resources|prompts)\/) so unknown future methods (ping, initialize, tasks/*, sampling/*, etc.) are silently skipped rather than requiring an explicit exclusion list. Use a WeakMap (toolNames) for O(1) tool name lookup in McpServerToolCallPlugin instead of a linear _registeredTools scan. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…helpers, clarify tool name lookup
- Replace bare diagnostic channels with tracingChannel('apm:mcp:server:request')
so the instrumentation follows the same convention as undici and other plugins;
update McpServerRequestPlugin prefix to 'tracing:apm:mcp:server:request' and
rename finish() → asyncEnd() to match TracingChannel semantics
- Extract tagRequestParams / tagRequestResult helpers in tracing.js to remove
inline tag logic from bindStart / asyncEnd
- Add comment on _createRegisteredTool shimmer explaining it populates toolNames
WeakMap in tracing.js for O(1) resource tag on McpServerToolCallPlugin spans
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…t span coverage Introduce McpPlugin base class so each plugin only declares static id/prefix/ spanName/kind and overrides getResource/onStart/onEnd — eliminating duplicated bindStart/asyncEnd boilerplate across all four plugins. Add client-side spans for the remaining MCP operations that previously had none: - mcp.resources.list (Client.listResources) - mcp.resource.read (Client.readResource, resource = URI) - mcp.prompts.list (Client.listPrompts) - mcp.prompt.get (Client.getPrompt, resource = prompt name) Expand orchestrion rewriter config to use helper functions instead of repeating the ESM/CJS entry pair for every method; add the four new client methods and keep the existing server entry. Fix: use dc-polyfill channel instead of diagnostics_channel directly. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…lkit/modelcontextprotocol-sdk/server-tracing
…lkit/modelcontextprotocol-sdk/server-tracing
crysmags
left a comment
There was a problem hiding this comment.
All change requests were made, approved!
BridgeAR
left a comment
There was a problem hiding this comment.
Nice, this is looking very promising!
A few small things, while nothing major.
…ol-sdk/server-tracing
…ol-sdk/server-tracing
aad45e3 to
4e08adb
Compare
BridgeAR
left a comment
There was a problem hiding this comment.
Overall LGTM. There are a few smaller things but they are not blocking and could be improved as follow-up. I also believe we could change the architecture a bit for very minor performance improvements, but that is likely not going to show much and this seems a relatively straight forward implementation.
| function getPendingContext (protocol, requestId) { | ||
| return pendingContexts.get(protocol)?.get(requestId) |
| function getServerRequestResource (request) { | ||
| if (request?.method === 'tools/call') return SERVER_TOOL_CALL_RESOURCE | ||
| return request?.method | ||
| } |
There was a problem hiding this comment.
Nit: I would inline this method
| this.addSub('apm:mcp:client:request:inject', this.injectTraceContext.bind(this)) | ||
| } | ||
|
|
||
| injectTraceContext (ctx) { |
There was a problem hiding this comment.
Nit: the class method should ideally just be inlined, since it is not used elsewhere as far as I can tell
| proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), 'server.mjs', agent.port, { | ||
| NODE_OPTIONS: nodeOptions, | ||
| }) | ||
|
|
||
| await res |
There was a problem hiding this comment.
Nit
| proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), 'server.mjs', agent.port, { | |
| NODE_OPTIONS: nodeOptions, | |
| }) | |
| await res | |
| ([proc] = await Promise.all([ | |
| spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), 'server.mjs', agent.port, { | |
| NODE_OPTIONS: nodeOptions, | |
| }), | |
| res, | |
| ]) |
| await testSetup.clientCallTool('empty-error-tool') | ||
|
|
||
| return await traceAssertion |
There was a problem hiding this comment.
Nit (these are about theoretical unhandled rejections. We just have a lot of tests written like this and due to the way the helper works it should be safe)
| await testSetup.clientCallTool('empty-error-tool') | |
| return await traceAssertion | |
| await Promise.all([ | |
| testSetup.clientCallTool('empty-error-tool'), | |
| traceAssertion, | |
| ]) |
…and client operations (#8916) This makes MCP protocol activity visible in APM across server requests, tool handlers, and client operations while preserving trace relationships across process boundaries through MCP metadata propagation. Span tags remain low-cardinality without copying MCP payloads, while errors from failed handlers and tool results remain observable.
…and client operations (#8916) This makes MCP protocol activity visible in APM across server requests, tool handlers, and client operations while preserving trace relationships across process boundaries through MCP metadata propagation. Span tags remain low-cardinality without copying MCP payloads, while errors from failed handlers and tool results remain observable.
What does this PR do?
Adds APM tracing for
@modelcontextprotocol/sdkserver requests and client operations.Server-side:
mcp.requestspans for handled MCP requests, parented under inbound transport spans when available.server_tool_callfortools/call.mcp.server.tool.callspans forMcpServertool handler execution.Client-side:
mcp.request/client_tool_callforClient.callTool().mcp.request/ClientSession.list_toolsforClient.listTools().mcp.resources.list,mcp.resource.read,mcp.prompts.list, andmcp.prompt.get.Tagging:
isErrortool-call results.Motivation
MCP clients and servers are protocol infrastructure used by AI applications. These spans make MCP tool/resource/prompt activity visible in APM and preserve trace relationships across client/server boundaries while keeping common span shapes aligned with the Python tracer where the SDKs overlap.
Additional Notes
origin/masterthrough4398f179c.28da2a61d.Local verification:
env -u OTEL_TRACES_EXPORTER -u OTEL_LOGS_EXPORTER -u OTEL_METRICS_EXPORTER PLUGINS=modelcontextprotocol-sdk npm run test:plugins:ci- passed, 51 passing../node_modules/.bin/eslint packages/datadog-plugin-modelcontextprotocol-sdk/src/tracing.js packages/datadog-plugin-modelcontextprotocol-sdk/src/utils.js packages/datadog-plugin-modelcontextprotocol-sdk/test/index.spec.js packages/datadog-plugin-modelcontextprotocol-sdk/test/integration-test/client.spec.js --max-warnings 0- passed.git diff --check- passed.