Skip to content

OpenTelemetry MCP

View Markdown

Status: Stable Package: dev.vertique.mcp.opentelemetry Artifact: vertique-opentelemetry-mcp Depends on: vertique-mcp-core, vertique-core API reference: javadoc.io

Observe-only OpenTelemetry span-enrichment adapter for the MCP server. When installed alongside McpServerModule, it captures the current Vert.x HTTP server span in open(...), retains it across asynchronous completion, and enriches that exact retained span with bounded MCP attributes. It also adds at most one link for a client-supplied body trace reference — see “At most one body-trace link” below — and never creates or renames a span.

The module compiles against the OpenTelemetry API only — no SDK dependency. Without an OpenTelemetry SDK installed (or with a no-op OpenTelemetry instance and no exporter configured), every span operation resolves to the API’s built-in no-op implementation: enrichment is skipped entirely and MCP protocol behavior is unaffected either way.


Install vertique-opentelemetry-mcp whenever an application exposes the MCP server via McpServerModule and wants its HTTP server spans (produced by Vert.x’s own OpenTelemetry tracing integration) enriched with MCP-specific facts. Omitting this module entirely is also supported: open-core MCP has no dependency on it, and MCP protocol behavior is identical either way.


Capture-at-open, never Span.current() at callback time. open(...) runs while the Vert.x HTTP server span is current — before dispatch, exactly as REST’s ServerSpanEnrichmentContributor/ ServerSpanOutcomeInterceptor capture the span early for the same reason. The observer resolves Span.current() exactly once, inside open, and retains that exact Span reference (and its SpanContext) on the returned session. The terminal callback later enriches that retained reference directly — it never re-resolves Span.current(). This matters because a terminal callback can be delivered on a different thread or after the request-owning context has moved on, where Span.current() would return a different span (or the no-op span) rather than the one this request actually ran under.

No span is ever created or renamed. This observer calls no tracer and builds no span. It only mutates the one span already current when open runs — and only when that span’s context is valid (an installed OpenTelemetry SDK with active tracing). Transport status remains owned by Vert.x HTTP tracing; this observer never calls Span#setStatus.

Enrichment happens at logical settlement (onTerminal), not at transport completion. The observer implements onTerminal, not onCompleted: attributes are set once the request settles logically, before the response is written, mirroring the contract’s “sets bounded logical outcome before the terminal write.”

Bounded attributes only.

  • rpc.system.name — always the literal jsonrpc.
  • mcp.method.name — an McpMethod name, with McpMethod.OTHER remapped to the underscore-prefixed literal _OTHER, mirroring the sibling Micrometer adapter’s method tag convention.
  • mcp.protocol.version — the terminal event’s negotiated protocol version, set only when non-null: a request whose protocol negotiation never completed (rejected at or before negotiation) sets no value here at all, rather than a hardcoded or default literal.
  • vertique.mcp.outcome — the terminal event’s McpOutcome enum name.
  • vertique.mcp.result.type — the terminal event’s McpResultType enum name.
  • gen_ai.tool.name — set only when the terminal event carries a resolved tool identity (never the UNKNOWN placeholder).

rpc.system.name, mcp.method.name, mcp.protocol.version, and gen_ai.tool.name are experimental OpenTelemetry semantic-convention names; rather than depending on an incubating semconv artifact, they are declared as internal, Vertique-owned AttributeKey constants on McpServerSpanObserver.

At most one body-trace link, never a child span, and only under mcp.bodyTracePolicy=LINK. When the request’s terminal observation carries a non-null linkedTrace (a dev.vertique.core.correlation.TraceReference — the framework’s single trace-reference type, replacing the deleted MCP-local McpTraceContext — extracted by McpProtocolCodec from the request body’s params._meta.traceparent/tracestate and bound onto McpCompletionCoordinator by McpRequestDispatcher), the observer converts it into an OpenTelemetry SpanContext and adds exactly one Span#addLink when that context is valid and from a different trace than the HTTP span’s own captured trace id. A body trace context sharing the HTTP span’s own trace id is suppressed as a self-reference and adds no link — this is the shape a body reference identical to the HTTP traceparent header actually takes: the header shares the captured span’s trace id (only the span id differs, since the captured span’s own span id is freshly minted at open and never equals its parent’s), so suppression compares trace id only, never full span identity. Malformed trace-state data (not well-formed W3C key=value entries) is caught and logged at WARN with a bounded diagnostic — the exception class name only, never the raw trace data — and adds no link; every other enrichment attribute is still recorded.

This observer performs no extraction of its own — the policy lives in vertique-mcp-server. The MCP server module’s mcp.bodyTracePolicy defaults to IGNORE (mirroring Vert.x’s own TracingPolicy default-off posture): under IGNORE, linkedTrace is always null on every terminal observation this observer receives, so no link is ever added and no extraction cost is ever paid — regardless of whether this adapter is installed. Set mcp.bodyTracePolicy=LINK on the MCP server configuration (not on this module) to opt into the extraction and linking behavior described above; see vertique-mcp-server’s own module.md (“Body trace-context extraction”) for the extraction and bounds mechanics.

Zero-overhead when unconfigured. Every operation is guarded by Span#getSpanContext().isValid() (at open), Span#isRecording() (at enrichment — a late terminal callback can observe a span that already ended between capture and enrichment), and try/catch, so a missing OpenTelemetry SDK, a no-op OpenTelemetry instance, or a throwing OpenTelemetry implementation never affects MCP request processing.


Dagger @Module. The only public type in this artifact. Install it explicitly to contribute one McpRequestLifecycleObserver that captures the current HTTP server span at open and enriches that retained span at logical settlement (bounded MCP attributes and at most one body-trace link when the server’s mcp.bodyTracePolicy is LINK). The package-private observer session also implements McpCompletionScope so co-installed Micrometer recording can run with a valid span current for exemplars.

@Component(modules = {
VertxModule.class,
McpServerModule.class,
McpOpenTelemetryModule.class,
// ...
})
interface AppComponent { /* ... */ }

Type Qualifier Description
McpRequestLifecycleObserver @IntoSet HTTP server span capture + enrichment

Owns no module-local configuration keys. Body-trace linking is gated by mcp.bodyTracePolicy on vertique-mcp-server. Observe-only: failures never alter MCP request processing.


  • io.opentelemetry:opentelemetry-api — Span, SpanContext, TraceFlags, TraceState, AttributeKey; no OpenTelemetry SDK dependency (NFR-TEL-002).
  • dev.vertique:vertique-mcp-core — McpRequestLifecycleObserver, McpRequestObservation, McpRequestTerminalObservation, McpRequestTerminalEvent, and the bounded lifecycle enums.
  • dev.vertique:vertique-core — dev.vertique.core.correlation.TraceReference, the framework’s single trace-reference type, referenced directly by this module’s main source rather than merely reachable transitively.
  • com.google.dagger:dagger, jakarta.inject:jakarta.inject-api
  • org.slf4j:slf4j-api, org.projectlombok:lombok (provided)