Hey — now that #97/#98/#100 are in, I'd like to contribute back the OpenTelemetry instrumentation I've been running on my fork. I operate MCP servers in production and tracing has been really helpful for debugging tool-call latency and correlating MCP spans with upstream HTTP traffic and downstream calls.
A working version already lives in getlarge#3 — this issue is about porting it cleanly on top of current main.
Intent
Add optional OTel tracing so operators can see MCP operations end-to-end, without imposing any cost on users who don't need it. MCP already has semantic conventions registered with OpenTelemetry, which makes this a natural fit.
Goals
- Zero cost when telemetry is not configured — no static imports of
@opentelemetry/api, no runtime overhead
@opentelemetry/api as an optional peer dependency — the package must load cleanly for users without it installed
- Every MCP JSON-RPC method produces a child span when a tracer is provided:
initialize, ping
tools/list, tools/call
resources/list, resources/templates/list, resources/read, resources/subscribe, resources/unsubscribe
prompts/list, prompts/get
logging/setLevel
- Span attributes follow the MCP semconv:
mcp.method.name, mcp.tool.name, mcp.resource.uri, mcp.session.id, etc.
- Errors are recorded on spans with
SpanStatusCode.ERROR and the exception attached
Non-goals
- Metrics or logs — tracing only for a first pass
- Auto-instrumenting user tool/resource/prompt handlers — users bring their own spans inside their handlers
- Making OTel a hard dependency
Plan
- Optional peer dep:
@opentelemetry/api in peerDependencies + peerDependenciesMeta.optional = true
src/telemetry.ts: centralised withSpan(tracer, name, attrs, fn) helper that lazy-loads SpanStatusCode via dynamic import() the first time it's called, then caches it. If no tracer is configured, withSpan is never called.
src/telemetry-constants.ts: MCP_ATTR constants mapping to the MCP semconv names, plus a buildSpanAttributes(method, params, sessionId) helper. Kept separate from telemetry.ts so the constants can be imported without touching OTel types.
- Extend
MCPPluginOptions: add telemetry?: { tracer: TracerLike } where TracerLike is a structural type, so the plugin doesn't leak OTel types into its public surface.
- Thread a
tracer through HandlerDependencies: handlers in src/handlers.ts wrap their core logic with withSpan when deps.tracer is defined.
- Exports: re-export
MCP_ATTR, buildSpanAttributes, withSpan, and the HandlerDependencies type from the package root for advanced consumers.
- Tests:
test/telemetry.test.ts — unit tests for withSpan and buildSpanAttributes
test/telemetry-types.test.ts — type-level tests for MCPPluginOptions.telemetry
test/telemetry-integration.test.ts — end-to-end: register the plugin with an in-memory tracer, drive JSON-RPC calls, assert spans emitted with the right names/attributes and error status on failures
- README: short usage section showing how to pass a tracer from
@opentelemetry/api.
Usage (target API)
```typescript
import { trace } from '@opentelemetry/api'
import mcpPlugin from '@platformatic/mcp'
await app.register(mcpPlugin, {
telemetry: {
tracer: trace.getTracer('my-mcp-server', '1.0.0'),
},
})
```
Open questions
- Span kind for the top-level JSON-RPC dispatch:
INTERNAL or SERVER? Leaning INTERNAL since the Fastify HTTP span is already SERVER, but happy to go either way.
- Should v1 also instrument the SSE broadcast path, or keep scope to request/response handlers? I'd default to request/response only and add SSE later if there's demand.
I'll open a PR with my take on the design as a starting point for discussion — happy to iterate on shape and scope from there.
Hey — now that #97/#98/#100 are in, I'd like to contribute back the OpenTelemetry instrumentation I've been running on my fork. I operate MCP servers in production and tracing has been really helpful for debugging tool-call latency and correlating MCP spans with upstream HTTP traffic and downstream calls.
A working version already lives in getlarge#3 — this issue is about porting it cleanly on top of current
main.Intent
Add optional OTel tracing so operators can see MCP operations end-to-end, without imposing any cost on users who don't need it. MCP already has semantic conventions registered with OpenTelemetry, which makes this a natural fit.
Goals
@opentelemetry/api, no runtime overhead@opentelemetry/apias an optional peer dependency — the package must load cleanly for users without it installedinitialize,pingtools/list,tools/callresources/list,resources/templates/list,resources/read,resources/subscribe,resources/unsubscribeprompts/list,prompts/getlogging/setLevelmcp.method.name,mcp.tool.name,mcp.resource.uri,mcp.session.id, etc.SpanStatusCode.ERRORand the exception attachedNon-goals
Plan
@opentelemetry/apiinpeerDependencies+peerDependenciesMeta.optional = truesrc/telemetry.ts: centralisedwithSpan(tracer, name, attrs, fn)helper that lazy-loadsSpanStatusCodevia dynamicimport()the first time it's called, then caches it. If no tracer is configured,withSpanis never called.src/telemetry-constants.ts:MCP_ATTRconstants mapping to the MCP semconv names, plus abuildSpanAttributes(method, params, sessionId)helper. Kept separate fromtelemetry.tsso the constants can be imported without touching OTel types.MCPPluginOptions: addtelemetry?: { tracer: TracerLike }whereTracerLikeis a structural type, so the plugin doesn't leak OTel types into its public surface.tracerthroughHandlerDependencies: handlers insrc/handlers.tswrap their core logic withwithSpanwhendeps.traceris defined.MCP_ATTR,buildSpanAttributes,withSpan, and theHandlerDependenciestype from the package root for advanced consumers.test/telemetry.test.ts— unit tests forwithSpanandbuildSpanAttributestest/telemetry-types.test.ts— type-level tests forMCPPluginOptions.telemetrytest/telemetry-integration.test.ts— end-to-end: register the plugin with an in-memory tracer, drive JSON-RPC calls, assert spans emitted with the right names/attributes and error status on failures@opentelemetry/api.Usage (target API)
```typescript
import { trace } from '@opentelemetry/api'
import mcpPlugin from '@platformatic/mcp'
await app.register(mcpPlugin, {
telemetry: {
tracer: trace.getTracer('my-mcp-server', '1.0.0'),
},
})
```
Open questions
INTERNALorSERVER? LeaningINTERNALsince the Fastify HTTP span is alreadySERVER, but happy to go either way.I'll open a PR with my take on the design as a starting point for discussion — happy to iterate on shape and scope from there.