Skip to content

Add optional OpenTelemetry instrumentation for MCP operations #128

@getlarge

Description

@getlarge

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

  1. Optional peer dep: @opentelemetry/api in peerDependencies + peerDependenciesMeta.optional = true
  2. 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.
  3. 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.
  4. Extend MCPPluginOptions: add telemetry?: { tracer: TracerLike } where TracerLike is a structural type, so the plugin doesn't leak OTel types into its public surface.
  5. Thread a tracer through HandlerDependencies: handlers in src/handlers.ts wrap their core logic with withSpan when deps.tracer is defined.
  6. Exports: re-export MCP_ATTR, buildSpanAttributes, withSpan, and the HandlerDependencies type from the package root for advanced consumers.
  7. 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
  8. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions