Introduction
MCP's built-in logging gives you visibility within a single server. OpenTelemetry gives you visibility across your entire infrastructure. By instrumenting MCP servers with OpenTelemetry, you get distributed tracing, metrics export to Prometheus and Grafana, and the ability to correlate MCP tool invocations with downstream service calls.
Key Concepts
- OpenTelemetry (OTel): A vendor-neutral observability framework for generating, collecting, and exporting traces, metrics, and logs. It is the industry standard for distributed observability.
- Spans: The building blocks of traces. Each tool invocation becomes a span with a start time, duration, attributes, and status. Spans nest to show parent-child relationships.
- Trace Context Propagation: Passing trace identifiers across service boundaries so that spans from different services can be stitched together into a single trace. For MCP, this means propagating context from the host through the client to the server and into downstream calls.
- Exporters: Components that send telemetry data to backends. Common exporters include OTLP (OpenTelemetry Protocol) for Jaeger or Tempo, and Prometheus for metrics scraping.
- Metrics Instruments: Counters, histograms, and gauges that track quantitative measurements. A histogram for tool latency gives you percentile breakdowns automatically.
Real World Context
Your organization runs five MCP servers behind a gateway: one for database queries, one for file operations, one for Slack, one for JIRA, and one for internal APIs. When an LLM agent orchestrates a complex task that touches all five servers, a single user request generates dozens of tool invocations across multiple services. Without distributed tracing, debugging a slow request requires checking five separate log streams. With OpenTelemetry, you open Jaeger, search by trace ID, and see every span in a waterfall view showing exactly where time was spent.
Deep Dive
Setting Up OpenTelemetry in an MCP Server
Install the required packages and initialize the OTel SDK before creating your MCP server:
typescriptimport { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; import { Resource } from "@opentelemetry/resources"; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; const sdk = new NodeSDK({ resource: new Resource({ [ATTR_SERVICE_NAME]: "mcp-database-server", [ATTR_SERVICE_VERSION]: "1.2.0" }), traceExporter: new OTLPTraceExporter({ url: "http://jaeger:4318/v1/traces" }), metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: "http://prometheus:4318/v1/metrics" }), exportIntervalMillis: 15000 }) }); sdk.start();
This initializes the OTel SDK with a service name, a trace exporter pointing at Jaeger, and a metrics exporter. The SDK must be started before your MCP server begins handling requests.
Wrapping Tool Invocations with Spans
Create spans around each tool invocation to capture timing, parameters, and outcomes:
typescriptimport { trace, SpanStatusCode } from "@opentelemetry/api"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; const tracer = trace.getTracer("mcp-database-server", "1.2.0"); const server = new McpServer({ name: "db-server", version: "1.2.0" }); server.tool( "query_database", "Execute a read-only SQL query", { sql: z.string(), params: z.array(z.string()).optional() }, async ({ sql, params }) => { return tracer.startActiveSpan("tool.query_database", async (span) => { span.setAttribute("mcp.tool.name", "query_database"); span.setAttribute("db.statement", sql); span.setAttribute("db.system", "postgresql"); try { const result = await executeQuery(sql, params); span.setAttribute("db.rows_affected", result.rowCount); span.setStatus({ code: SpanStatusCode.OK }); return { content: [{ type: "text", text: JSON.stringify(result.rows) }] }; } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); span.recordException(error); return { content: [{ type: "text", text: `Query error: ${error.message}` }], isError: true }; } finally { span.end(); } }); } );
Each span captures the tool name, SQL statement, row count, and any errors. In Jaeger, these spans appear as individual operations within a trace, showing exactly how long the database query took.
Propagating Trace Context Across MCP Boundaries
In an HTTP-transported MCP server, trace context arrives in request headers. Extract it so that your server spans are linked to the client's trace:
typescriptimport { propagation, context } from "@opentelemetry/api"; // In your HTTP middleware, before MCP handles the request: app.use("/mcp", (req, res, next) => { const parentContext = propagation.extract( context.active(), req.headers ); context.with(parentContext, () => next()); });
This ensures that spans created inside your MCP tool handlers are children of the span created by the MCP client, producing a complete trace from host to server.
Metrics with OTel Instruments
Define counters and histograms to track tool-level metrics:
typescriptimport { metrics } from "@opentelemetry/api"; const meter = metrics.getMeter("mcp-database-server"); const toolInvocations = meter.createCounter("mcp.tool.invocations", { description: "Number of tool invocations" }); const toolLatency = meter.createHistogram("mcp.tool.duration", { description: "Tool invocation duration in milliseconds", unit: "ms" }); // Inside tool handler: toolInvocations.add(1, { "mcp.tool.name": "query_database" }); toolLatency.record(durationMs, { "mcp.tool.name": "query_database" });
These metrics are automatically exported to your Prometheus instance, where you can build Grafana dashboards showing invocation rates, latency distributions, and error rates per tool.
Common Pitfalls
- Forgetting to end spans — An unended span leaks memory and produces incomplete traces. Always call
span.end()in a finally block to guarantee cleanup even when exceptions occur. - Over-instrumenting — Creating spans for every internal function call produces noisy traces. Instrument at meaningful boundaries: tool invocations, database calls, and HTTP requests to downstream services.
- Not setting service name — Without a distinct service name in the OTel resource, traces from multiple MCP servers merge into an indistinguishable blob in your tracing UI.
Best Practices
- Use semantic conventions for span attributes — Follow OpenTelemetry semantic conventions (e.g.,
db.system,db.statement,http.method) so your telemetry data is compatible with standard dashboards and queries. - Initialize OTel before MCP — The OpenTelemetry SDK must be started before your MCP server begins accepting connections. This ensures the first tool invocation is captured.
- Export metrics on a reasonable interval — A 15-second export interval balances freshness with overhead. Avoid sub-second intervals in production.
Summary
- OpenTelemetry provides vendor-neutral distributed tracing, metrics, and logging for MCP servers.
- Wrap tool invocations with spans to capture timing, attributes, and error status.
- Propagate trace context across MCP boundaries using OTel propagation APIs.
- Define counters and histograms for tool-level metrics and export to Prometheus/Grafana.
- Initialize the OTel SDK before starting the MCP server to ensure complete telemetry capture.
Code Examples
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("mcp-server", "1.0.0");
// Inside tool handler:
return tracer.startActiveSpan("tool.query_database", async (span) => {
span.setAttribute("mcp.tool.name", "query_database");
try {
const result = await executeQuery(sql);
span.setStatus({ code: SpanStatusCode.OK });
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
span.recordException(error);
throw error;
} finally {
span.end();
}
});