ADR-0030 — Align observability with the OpenTelemetry MCP semantic conventions¶
- Status: Accepted
- Date: 2026-07-30
Context¶
ADR-0017 established the Micrometer observation stack and stated that GenAI / MCP attributes are populated "for cross-tool consistency". A 1.0 review against the actual conventions found that claim overstated, and found the conventions had moved.
The conventions say "instead of", explicitly. The MCP semconv
document (docs/gen-ai/mcp.md)
resolves the layering question in prose: "When instrumenting MCP calls,
it's RECOMMENDED to follow MCP conventions instead of RPC semantic
conventions since MCP spans and metrics provide domain-specific context
and record details that are not covered by the RPC conventions." The
JSON-RPC semconv does define client/server spans (as specializations of
the generic RPC span types in model/rpc/spans.yaml — an earlier draft
of this ADR wrongly said no JSON-RPC span exists); those conventions
govern plain JSON-RPC calls, which is exactly what ripcurl's default
still emits for non-MCP services. For MCP calls, the MCP span is the
one span representing the RPC — it carries jsonrpc.request.id,
jsonrpc.protocol.version, and rpc.response.status_code in its own
attribute list. Same relationship as gRPC's conventions to the generic
RPC ones: the most specific convention owns the span.
The conventions relocated. mcp.method.name, mcp.protocol.version,
mcp.resource.uri, and mcp.session.id are now marked Deprecated in
open-telemetry/semantic-conventions and live in
open-telemetry/semantic-conventions-genai (model/mcp/). That
repository is the authority for this ADR. Every MCP attribute is
development stability — this is a moving target, and the alignment
here is to a snapshot, not a frozen contract.
What mocapi already had right. The three registry attributes we emit
(mcp.method.name, mcp.protocol.version, mcp.resource.uri) are
correct, as are gen_ai.operation.name=execute_tool,
gen_ai.tool.name, and gen_ai.prompt.name. mcp.session.id is
correctly absent — mocapi is stateless (ADR-0020).
What ripcurl already had right. The jsonrpc.server observation from
ripcurl-o11y emits jsonrpc.request.id, jsonrpc.protocol.version,
rpc.system.name, rpc.response.status_code, and error.type as the
JSON-RPC error code string. Each of those was checked against the current
model/jsonrpc/registry.yaml and model/rpc/registry.yaml during this
review and all five are current — rpc.response.status_code is even
release_candidate, a firmer stability tier than any MCP attribute.
MCP semconv inherits the JSON-RPC conventions for exactly these, so that
half needed no work.
Consequently ripcurl is not modified and no ripcurl release is required. This was checked rather than assumed, because the option of changing ripcurl was on the table; recording it here so the question isn't reopened without new evidence.
What was wrong. The observation name mcp.handler.execution yields
the metric mcp.handler.execution.duration, where semconv defines
mcp.server.operation.duration. The span name was the bare target name
(echo) rather than the prescribed {mcp.method.name} {target}
(tools/call echo). error.type on mocapi's own observation was
e.getClass().getSimpleName() rather than the JSON-RPC error code, and
nothing emitted tool_error for a CallToolResult with isError=true
as the conventions require. network.transport was absent.
mcp.client.name is not a convention at all — it has no counterpart in
the registry.
Decision¶
Emit the semconv MCP server operation from a MethodInterceptor
registered at the JSON-RPC method level, through ripcurl's
JsonRpcMethodHandlerCustomizer SPI — not from a server decorator, not
from new convention machinery in ripcurl, and not from mocapi's
per-handler OBSERVATION stratum.
The placement is load-bearing, because mocapi has two distinct invoker chains and they bracket different intervals:
- ripcurl's chain wraps each
@JsonRpcMethodhandler —tools/calldispatching intoMcpToolsService.callTool. This brackets the whole MCP method: envelope handling, validation, guards, the handler, and result serialization. It is the interval the conventions mean by "received until the result or ack is sent". - mocapi's chain (
MutableHandlerState: correlation, observation, audit, validation, invocation) wraps the user's@McpToolmethod only. It is strictly narrower.
mcp.server.operation.duration must measure the former. Attaching the
compliant observation to mocapi's OBSERVATION stratum would silently
under-report it by excluding everything the framework does around the
user handler — a metric that is wrong by construction, which defeats the
purpose of adopting the convention at all.
The existing mcp.handler.execution observation stays where it is, on
mocapi's chain, as a deliberate non-standard child. It measures user
handler time in isolation, which is genuinely useful for separating
application cost from framework overhead, and it is documented as a
mocapi extension rather than as a convention. It is slimmed to handler
kind + target name: the semconv attributes it used to carry move to the
server span, where the conventions place them.
Two consolidations follow from the one-span model:
- ripcurl's
jsonrpc.serverobservation backs off — not by suppression, but through a seam added to ripcurl for the purpose (2.12.0): its observation customizer is now the named, replaceableJsonRpcObservationCustomizerbean, registered@ConditionalOnMissingBean. mocapi'sMcpServerOperationCustomizerimplements that interface, so ripcurl's default backs off exactly the way any Spring Boot default bean does when the application provides its own. ripcurl is not "wrong" to emit a JSON-RPC observation standing alone; the conventions simply put the JSON-RPC attributes on the MCP server span when a more specific protocol layers over it — one span, owned by the most specific convention (the same layering rule that has Spring MVC emithttp.serverwith no separate servlet span beneath it). Nothing is lost: the server-operation observation emits every attribute ripcurl's did, with identical semantics. mocapi's o11y autoconfiguration is orderedbeforeNameripcurl's so the back-off condition sees the bean; a context-runner test pins that ordering, because the failure mode of getting it wrong is silent double-observation. - Remote trace-parenting moves to the server span. The
McpRequestReceiverContext(W3C extraction from_meta,Kind.SERVER) previously hung off the inner handler observation, which produced an odd topology: the client's trace linked to the handler span while the outer span parented to the local HTTP span. The server-operation observation now owns the receiver context, so the client trace links to the span the conventions say should carry it — and span kindSERVERcomes with it, closing what ripcurl had documented as its own gap. The handler child uses a plain context and parents locally.
Concretely:
- Observation name is
mcp.server.operation, producing the prescribedmcp.server.operation.durationhistogram. The JSON-RPC method name is closed over at customizer time, so the hot path does no reflection — the same shape ripcurl uses for its own interceptor. - Span name becomes
{mcp.method.name} {target}, where target isgen_ai.tool.nameorgen_ai.prompt.name. Where no low-cardinality target exists, the span name is{mcp.method.name}alone. The resource URI is not used as a target by default — the conventions warn it produces high-cardinality span names. mcp.method.nameis emitted as a low-cardinality attribute. It isrequiredfor this span and metric, and was previously only on the outer observation.error.typeis the string form of the JSON-RPC error code, obtained through ripcurl'sJsonRpcExceptionTranslatorRegistryso it always matches the code the client actually receives; andtool_errorwhen a tool returnsCallToolResult.isError=true, which the conventions call out specifically.rpc.response.status_codecarries the same code on failure.network.transportistcpfor Streamable HTTP andpipefor stdio, resolved by the autoconfiguration from the transport module on the classpath rather than sniffed per call.mcp.client.nameis dropped. It is not in the registry and has no standard counterpart. Client identity remains available through MDC for logging; it does not belong on a span claiming semconv compliance.
mcp.handler.kind is kept as a deliberate mocapi extension — it has no
registry counterpart but is useful for filtering, and custom attributes
alongside the standard set do not breach compliance.
Not adopted, and recorded so the gap is a decision rather than an
oversight: client.address / client.port (recommended "if applicable";
they require servlet-level access the observation stratum does not have),
gen_ai.tool.call.arguments / gen_ai.tool.call.result /
gen_ai.prompt.variable (all opt_in, and all capable of carrying
sensitive payloads), network.protocol.version (per-request servlet
information unavailable at this stratum), and
mcp.client.session.duration / mcp.server.session.duration (sessions
do not exist in a stateless server). One partial adoption: on
tool_error the attribute is set but span status stays unset — there is
no throwable to record on that path, and the attribute is the queryable
signal the conventions emphasize. And one looseness accepted knowingly:
the registry's mcp.method.name enum predates the 2026-07-28 revision
(it still lists initialize, roots, and sampling methods), so values
like server/discover are emitted as legal open-enum extensions.
Consequences¶
What this buys us. Telemetry that off-the-shelf OTel tooling
recognizes without per-deployment mapping: the metric has the name
dashboards expect, spans read as tools/call echo, and failures carry
the error taxonomy the conventions define, including the tool-level
tool_error distinction that a generic exception name cannot express.
Costs. Metric and span names change, which breaks any existing
dashboard or alert built on mcp.handler.execution.duration. This is
deliberately taken pre-1.0, while mocapi has no install base to disrupt —
the same change after 1.0 would be a breaking telemetry migration. One
attribute (mcp.client.name) is removed outright.
Stability risk, stated plainly. Every MCP attribute in the registry
is development stability and the conventions were relocated to a new
repository recently enough that the old location still carries the
deprecated copies. Alignment will need revisiting; this ADR pins the
snapshot it was written against so a future reviewer can diff rather than
guess.
Non-goals. This does not change the audit or MDC subsystems, the interceptor strata order, or ripcurl. It does not add opt-in payload attributes, and it does not introduce session metrics that a stateless server cannot produce.
Code anchors:
mocapi-o11y/src/main/java/com/callibrity/mocapi/o11y/McpServerOperationInterceptor.java
(the compliant mcp.server.operation observation, registered via ripcurl's
JsonRpcMethodHandlerCustomizer; carries the McpRequestReceiverContext, so
remote trace-parenting and SERVER span kind live on the server span),
mocapi-o11y/src/main/java/com/callibrity/mocapi/o11y/McpServerOperationCustomizer.java
(implements ripcurl's JsonRpcObservationCustomizer seam, displacing the
default jsonrpc.server observation),
ripcurl-o11y's JsonRpcObservationCustomizer /
DefaultJsonRpcObservationCustomizer (ripcurl ≥ 2.12.0 — the replaceable
observation-owner seam),
mocapi-o11y/src/main/java/com/callibrity/mocapi/o11y/McpHandlerObservationInterceptor.java
(the narrower, non-standard mcp.handler.execution child — kind + target
only), and
mocapi-autoconfigure/src/main/java/com/callibrity/mocapi/o11y/MocapiO11yAutoConfiguration.java
(customizer registration, autoconfig ordering, and network.transport
resolution). McpObservationFilter is deleted — its two convention
attributes moved onto the server-operation observation, and
mcp.client.name is dropped (client identity stays in MDC via
mocapi-logging).
Dependency note: this decision raises mocapi's ripcurl floor to 2.12.0 (the release that introduces the observation-owner seam). ripcurl 2.12.0 must be released to Maven Central before mocapi 1.0.0 is tagged — Central rejects SNAPSHOT dependencies.