feat(mcp): add native OpenTelemetry observability - #3041
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds first-class OpenTelemetry observability to the @upstash/context7-mcp server by instrumenting MCP operations at the SDK transport boundary (including batched JSON-RPC messages), exporting MCP server spans/metrics, and exposing Prometheus/OpenMetrics on a dedicated internal listener.
Changes:
- Add MCP operation/session instrumentation (
SERVERspans +mcp.server.operation.duration/mcp.server.session.duration) with SEP-414 context extraction fromparams._meta. - Add bounded application metrics for upstream Context7 API calls, tool outcomes, authentication outcomes, and Node runtime signals, plus an embedded Prometheus exporter on
:9464/metrics. - Add shutdown handling for stdio to close/flush reliably, and add tests covering lifecycle, disabled-path behavior, batching, and exporter behavior.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
pnpm-lock.yaml |
Locks new OpenTelemetry dependencies used by the MCP package. |
packages/mcp/package.json |
Adds OpenTelemetry API/SDK/exporter and runtime instrumentation dependencies. |
packages/mcp/Dockerfile |
Exposes the dedicated Prometheus metrics port (9464) in the container image. |
packages/mcp/src/index.ts |
Wires telemetry into HTTP + stdio serving paths, adds auth/upstream observation, and conditional embedded exporter bootstrapping. |
packages/mcp/src/lib/api.ts |
Wraps Context7 upstream calls with observeUpstreamRequest and adds tool outcome classification for context fetches. |
packages/mcp/src/lib/types.ts |
Extends ContextResponse with a bounded outcome for tool telemetry. |
packages/mcp/src/lib/tool-names.ts |
Centralizes tool names and defines bounded ToolCallOutcome. |
packages/mcp/src/lib/telemetry.ts |
Implements bounded upstream/auth metrics, tool outcome tagging, and upstream error classification. |
packages/mcp/src/lib/telemetry-config.ts |
Adds env-based switches for hard-off telemetry and embedded Prometheus enablement. |
packages/mcp/src/lib/telemetry-provider.ts |
Implements embedded Prometheus MetricReader + Node runtime metrics bootstrap with “fail open” behavior. |
packages/mcp/src/lib/mcp-telemetry.ts |
Adds transport-boundary MCP operation/session instrumentation and span/metric classification. |
packages/mcp/src/lib/mcp-operation-scope.ts |
Adds AsyncLocalStorage-based per-operation scope for tool outcome/error marking. |
packages/mcp/src/lib/stdio-shutdown.ts |
Adds idempotent stdio shutdown with bounded flush behavior. |
packages/mcp/README.md |
Documents signals, env configuration, scrape endpoint, and gateway non-duplication guidance. |
packages/mcp/test/telemetry.test.ts |
Unit tests for method/tool normalization, SEP-414 extraction, config flags, and span parenting/linking. |
packages/mcp/test/telemetry-disabled.test.ts |
Verifies OTEL_SDK_DISABLED=true yields no exported application metrics. |
packages/mcp/test/stdio-shutdown.test.ts |
Tests idempotent shutdown, error handling, and flush timeout behavior. |
packages/mcp/test/mcp-telemetry-lifecycle.test.ts |
Exercises MCP lifecycle classification, cancellations, transport errors, and session metric behavior. |
packages/mcp/test/integration.test.ts |
End-to-end validation of exported Prometheus series (batch counts, bounded labels, runtime metrics, exporter collision). |
.changeset/clean-otters-observe.md |
Publishes a minor release note for the new observability features. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
enesgules
left a comment
There was a problem hiding this comment.
Reviewed the full diff. The code is correct and well tested — no blocking bug found. One security-relevant default, one bundled behavior change, and a few non-blocking notes.
1. Metrics listener binds to 0.0.0.0 by default (should be localhost)
telemetry-provider.ts sets DEFAULT_PROMETHEUS_HOST = "0.0.0.0". The OpenTelemetry spec defines the default for OTEL_EXPORTER_PROMETHEUS_HOST as localhost. This package is also a CLI that users run with npx ... --transport http; they would now get an open port on all interfaces with no warning.
Suggestion: default to 127.0.0.1 and add ENV OTEL_EXPORTER_PROMETHEUS_HOST=0.0.0.0 to the Dockerfile for the container path. This keeps the spec default and the k8s deployment working.
2. Bundled behavior change: OAuth metadata fetch now has a timeout
index.ts adds OAUTH_METADATA_TIMEOUT_MS = 10_000 to the /.well-known/oauth-authorization-server fetch, which previously had no timeout. The change is good, but it's a behavior change (hung auth server → 502 after 10 s instead of hanging) and isn't called out in the PR description. Worth a mention there.
3. Minor: cached rejected import in api.ts
telemetryModule ??= import("./lib/telemetry.js") caches a rejected promise forever if the import fails once, after which every API call fails. In practice main() imports the same module eagerly first so the process would crash earlier — low risk, but a .catch that clears the cache would remove it.
4. Minor: small indirection that could fold away
tool-names.tsis a 7-line file for two string constants; they could live inmcp-telemetry.ts.index.tsandapi.tseach define their ownobserveUpstreamRequestwrapper with a different lazy-load strategy; one shared guard would do.@opentelemetry/instrumentation-runtime-nodepulls inrequire-in-the-middle/import-in-the-middlemodule-hooking machinery for runtime collectors only — the heaviest of the new deps for the least signal. Acceptable per the benchmark, just noting it.
What's done well
- Double-finish/double-decrement paths in
InstrumentedTransportare all guarded. - Cardinality is bounded everywhere; no user data in labels.
- Fail-open is consistent: exporter bind failure, runtime-collector failure, and flush timeout never block MCP serving.
- The test suite is unusually thorough (hard-off module-load verification via a loader hook, batch counting, abort/cancel/close lifecycle, port-collision fail-open).
Recommendation: fix the bind default (1) before merge; the rest are non-blocking.
|
Addressed the latest review in
The remaining maintainability notes are intentional:
Validation: 10 test files / 97 tests passed, plus typecheck, ESLint, Prettier, build, frozen-lockfile production Docker build, and |
enesgules
left a comment
There was a problem hiding this comment.
Re-reviewed the update. All three actionable findings from my previous review are addressed: the metrics listener now defaults to 127.0.0.1 with the Docker image explicitly opting into 0.0.0.0 (plus a loopback-bind integration test), the OAuth metadata timeout is documented, and the lazy telemetry import clears its cache on rejection so a failed import can no longer poison later API calls. The remaining notes were non-blocking. LGTM.
|
Final local production-readiness audit completed on head Review fixes added:
Validation:
Local Prometheus/Grafana was fed only synthetic traffic backed by a local API fixture. Verified operation counts/latency, bounded tool outcomes, upstream success/HTTP-error classes, authentication missing/accepted, active operations/dependencies, active v2 subscriptions, subscription duration/outcome, and Node event-loop/V8 metrics. Active gauges returned to zero. No generic inbound HTTP metrics were emitted, avoiding duplication with Envoy. No query, library ID, API key, client IP, authorization value, or session ID appeared in metric labels. Final 1 CPU / 256 MiB resource comparison, 3 interleaved fresh-container trials per mode, 2k warmup + 10k modern-v2
This is the worst relative case (tiny CPU-only No production or Kubernetes resources/data were accessed or mutated during this audit. |
|
Exact-head follow-up after the final plugin-auth merge/rebuild (
Both completed 5,000/5,000 requests with no OOM/restart and exited code 0 on SIGTERM. |
|
Finalized on head 2d03143 after a read-only production-cluster wiring check. No Kubernetes or production resources were mutated. The application endpoint remains 0.0.0.0:9464/metrics in the production image. The current cluster uses annotation-based kubernetes-pods discovery, has 10 stateless MCP replicas, and currently has zero active MCP scrape targets because the workload pod template has neither the Prometheus annotations nor a declared containerPort 9464. Both existing MCP Services expose only application port 3000. The README now documents the required per-pod annotations/port and explicitly avoids a single load-balanced Service target, which would mix replicas and yield incomplete per-process series. With that workload metadata, Prometheus will scrape every pod at http://:9464/metrics on the existing global 10-second interval. The existing Envoy scrape remains separate and healthy: five data-plane /stats/prometheus targets and one control-plane /metrics target are up. This PR intentionally does not duplicate Envoy-owned HTTP/proxy metrics. Final verification: 11 test files / 112 tests passed, Prettier and git diff checks passed, CI is green, and GitHub reports APPROVED, CLEAN, and MERGEABLE. The deployment metadata must be added through the infrastructure rollout before expecting MCP metrics in production. |
Summary
mcp.server.operation.durationandmcp.server.session.duration, plusSERVERspans with standard MCP/GenAI/JSON-RPC/network attributestraceparent,tracestate, andbaggagefromparams._metaper SEP-414, using the extracted context as parent and linking ambient HTTP context:9464/metricslistener while supporting externally preloaded OpenTelemetry providers0.0.0.0override in the production Docker imageOTEL_SDK_DISABLED=trueas a true hard-off boundary: no telemetry provider modules, transport wrappers, async scopes, spans, metrics, or runtime collectors are loadedWhy this integration
The MCP TypeScript SDK v2.0.0 does not ship a turnkey OpenTelemetry plugin, tracer/meter, exporter, or completed-operation middleware. It does expose SEP-414 propagation constants and the public transport boundary. Instrumenting that boundary observes protocol operations across HTTP and stdio without subclassing protected SDK internals or mistaking an HTTP envelope for a single MCP operation.
The cluster already has gateway HTTP and Kubernetes/container signals, but those cannot identify MCP methods, individual tool health, authentication outcomes, Context7 API dependency failures, or Node event-loop/heap pressure. This adds those missing bounded semantic signals without duplicating gateway request telemetry.
Envoy non-duplication audit
The checked-in production data path is Envoy Gateway ->
HTTPRoute/mcp->Service/mcp-svc-> MCP pods. Envoy is not a sidecar in the MCP pod.envoy_http_*_downstream_*envoy_cluster_upstream_*mcp_server_operation_duration_countis a JSON-RPC operation count after SDK dispatch, not an HTTP request count: one valid batch is one Envoy request and multiple MCP operations; pre-dispatch HTTP rejections are Envoy-onlycontext7_mcp_upstream_*observes MCP-to-Context7 API calls, which the ingress gateway cannot seeSignals
mcp_server_operation_duration— standard MCP server operation histogram;_countis the operation count. Tool calls use standardgen_ai.tool.nameplus boundedcontext7.mcp.tool.outcome(success,not_found, orerror), avoiding a duplicate tool counter/histogram.mcp_server_session_duration— standard MCP session histogram for real stateful stdio sessions; stateless HTTP request transports are excludedSERVERspans with SEP-414 parent extraction and ambient transport linkscontext7_mcp_operations_activecontext7_mcp_upstream_requests_total,context7_mcp_upstream_request_duration, andcontext7_mcp_upstream_requests_activecontext7_mcp_authentication_attempts_total,context7_mcp_authentication_duration, andcontext7_mcp_authentication_activenodejs_eventloop_*,v8js_gc_duration,v8js_memory_heap_*, andv8js_resource_activetarget_inforesource metadataUpstream outcomes distinguish HTTP, response-decoding, network, timeout, and cancellation failures; they include both bounded status class and exact numeric HTTP status. Authentication distinguishes accepted, missing, invalid, and unexpected-error outcomes.
Labels exclude API keys, IPs, client versions, queries, library IDs, session IDs, tool arguments/results, and raw error text. Server-side JSON-RPC caller faults remain visible through
rpc.response.status_codewithout settingerror.type; internal, transport, and tool failures set bounded error types.Operations
127.0.0.1:9464/metrics; the production Docker image explicitly setsOTEL_EXPORTER_PROMETHEUS_HOST=0.0.0.0for internal cluster scraping502on timeout instead of hanging indefinitelyOTEL_EXPORTER_PROMETHEUS_HOSTandOTEL_EXPORTER_PROMETHEUS_PORTconfigure the listenerOTEL_METRICS_EXPORTER=nonedisables only the embedded exporter, allowing a preloaded provider to receive signalsOTEL_SDK_DISABLED=trueentirely bypasses instrumentation and provider bootstrapMeterProvider/TracerProvidertakes precedence; external instrumentation owns Node runtime registration in that modeThe deployment repository still needs to expose port
9464internally and add the Prometheus scrape/ServiceMonitor; it should not route this port through public MCP ingress.Resource-overhead benchmark
Local Docker benchmark against the telemetry branch's then-current merge-base, using an in-memory/stubbed upstream to make telemetry cost maximally visible: 8 concurrent MCP
query-docsclients, 10-second warm-up, 30-second measurement, three repetitions, rotated mode order, and 15-second Prometheus scraping when enabled. Every measured call succeeded. The latermastersync and secure bind-default change do not alter the enabled instrumentation hot path.OTEL_SDK_DISABLED=trueMedian absolute CPU cost was
0.546 ms/opfor baseline,0.549 ms/opdisabled, and0.639 ms/openabled. Median throughput was 1815, 1801, and 1803 operations/second respectively. The enabled result is intentionally a worst-case CPU-saturated local test with essentially no upstream latency; production's network-bound requests should have a lower relative percentage, while the absolute memory and CPU measurements remain the useful capacity-planning bounds.The benchmarked production image grew from 115,663,904 to 118,468,819 bytes: +2,804,915 bytes (+2.43%).
Validation
pnpm typecheck— passedpnpm lint:check— passedpnpm format:check— passedpnpm build— passedpnpm test— 10 files / 97 tests passed after merging currentmasterOTEL_EXPORTER_PROMETHEUS_HOST=0.0.0.0not_found, authentication timing/concurrency, runtime metrics, batch counts, active-gauge cleanup, and exporter collisiontools/calloperation series exist and removedcontext7_mcp_tool_calls_*duplicate series do notReferences