Trace MCP calls
com.tnsai.mcp.observability adds spans and metrics to MCP traffic. You can trace at two levels. TracingMcpClient wraps an McpClient and opens one span per client call, such as executeTool. McpTracingInterceptor wraps an McpTransport and opens one span per JSON-RPC message. Both report spans to an McpTracer. The framework ships two tracers: one that does nothing and one that writes to SLF4J. To send spans anywhere else, implement McpTracer yourself.
Example
package com.example.tnsai.docs;
import com.fasterxml.jackson.databind.JsonNode;
import com.tnsai.mcp.client.McpClient;
import com.tnsai.mcp.client.McpTransport;
import com.tnsai.mcp.observability.McpMetricsCollector;
import com.tnsai.mcp.observability.McpSpan;
import com.tnsai.mcp.observability.McpTracer;
import com.tnsai.mcp.observability.McpTracingInterceptor;
import com.tnsai.mcp.observability.TracingMcpClient;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
/** Compilation contract for mcp/observability.md. */
public final class McpObservabilityExample {
private McpObservabilityExample() {
}
static Map<String, Object> traceCalls(McpTransport transport) throws IOException {
McpMetricsCollector metrics = new McpMetricsCollector();
TracingMcpClient client = new TracingMcpClient(
McpClient.create(transport), McpTracer.logging(), metrics); // spans log at DEBUG
client.connect();
client.initialize();
try {
client.listTools();
JsonNode content = client.executeTool("search", Map.of("q", "docs"));
System.out.println(content);
} finally {
client.disconnect();
}
return metrics.getSummary(); // invocation counts, average ms, error counts, active sessions
}
static McpClient traceMessages(McpTransport transport) {
// Every client, bridge or agent built on this transport gets one span per message.
return McpClient.create(new McpTracingInterceptor(transport, McpTracer.logging()));
}
/** A tracer that hands each finished span to your own sink. */
public static final class RecordingTracer implements McpTracer {
public record FinishedSpan(String traceId, String spanId, String operation,
McpSpan.SpanStatus status, Map<String, Object> attributes,
long durationNanos) {
}
private final AtomicLong nextSpanId = new AtomicLong();
private final Consumer<FinishedSpan> sink;
public RecordingTracer(Consumer<FinishedSpan> sink) {
this.sink = sink;
}
@Override
public McpSpan startSpan(String operationName) {
return startSpan(operationName, null);
}
@Override
public McpSpan startSpan(String operationName, McpSpan parent) {
String traceId = parent != null ? parent.getTraceId() : UUID.randomUUID().toString();
String spanId = Long.toHexString(nextSpanId.incrementAndGet());
long start = System.nanoTime();
Map<String, Object> attributes = new HashMap<>();
return new McpSpan() {
private SpanStatus status; // stays null when the wrapper does not set one
@Override
public void setAttribute(String key, String value) {
attributes.put(key, value);
}
@Override
public void setAttribute(String key, long value) {
attributes.put(key, value);
}
@Override
public void setAttribute(String key, double value) {
attributes.put(key, value);
}
@Override
public void setStatus(SpanStatus newStatus) {
status = newStatus;
}
@Override
public void recordException(Throwable t) {
attributes.put("exception", t.getClass().getName() + ": " + t.getMessage());
}
@Override
public void end() {
sink.accept(new FinishedSpan(traceId, spanId, operationName, status,
Map.copyOf(attributes), System.nanoTime() - start));
}
@Override
public String getTraceId() {
return traceId;
}
@Override
public String getSpanId() {
return spanId;
}
};
}
}
static TracingMcpClient recorded(McpClient client) {
return new TracingMcpClient(client, new RecordingTracer(System.out::println));
}
}Tracers
| Tracer | Behavior |
|---|---|
McpTracer.noop() | Returns a shared span that ignores every call. Its trace and span ids are "0000000000000000". |
McpTracer.logging() | Logs to the com.tnsai.mcp.observability.McpTracing logger. Span start, attributes, status and end log at DEBUG. An ERROR status and recorded exceptions log at WARN. |
The logging tracer takes the trace id from the parent span. With no parent, it takes 16 hex characters from a random UUID. Span ids come from a counter and are formatted as 16 hex digits.
Its end-of-span line uses a Python-style {:.2f} placeholder, which SLF4J does not understand. The line therefore prints the duration where the status belongs, the status where the attributes belong, and drops the attributes. Read attributes from their own DEBUG lines.
An McpSpan takes String, long and double attributes, an OK or ERROR status, and exceptions. end() closes it. To export spans to OpenTelemetry or another backend, implement McpTracer and McpSpan, as RecordingTracer in the example does.
Client wrapper
TracingMcpClient(client, tracer) or TracingMcpClient(client, tracer, metrics) wraps an existing McpClient. A null client or tracer throws IllegalArgumentException. The wrapper is not an McpClient itself, so you call it directly. getDelegate() returns the wrapped client, and getMetrics() returns the collector or null.
| Call | Span | Attributes |
|---|---|---|
connect() | mcp.connect | — |
initialize() | mcp.initialize | — |
disconnect() | mcp.disconnect | — |
listTools() | mcp.listTools | mcp.tools.count |
executeTool(name, args) | mcp.executeTool | mcp.tool.name |
listResources() | mcp.listResources | mcp.resources.count |
readResource(uri) | mcp.readResource | mcp.resource.uri, mcp.resource.parts |
listPrompts() | mcp.listPrompts | mcp.prompts.count |
getPrompt(name, args) | mcp.getPrompt | mcp.prompt.name |
isConnected() and isInitialized() pass through without a span.
Each span gets status OK on success. On an IOException, including McpClient.McpException, it gets status ERROR and the exception, and the exception is rethrown. A RuntimeException leaves the status unset; the span still ends. Every span starts without a parent, so each call begins a new trace.
Metrics
Pass an McpMetricsCollector to the wrapper to count calls.
connect()adds an active session when it succeeds.disconnect()removes one when it succeeds.executeToolrecords one invocation per call, success or failure, with its duration.- A failed call that the wrapper records adds an error under the key
<ExceptionSimpleName>:<tool>, for exampleMcpException:search. List, read and get calls use the operation name as the tool, such aslistTools.connect,initializeanddisconnectfailures are not counted.
| Method | Returns |
|---|---|
getToolInvocationCounts() | Successes plus failures per tool, sorted by tool name |
getToolAverageDurations() | Average duration per tool in milliseconds |
getErrorCounts() | Count per error key |
getActiveSessions() | Current number of active sessions |
getSummary() | The four above under toolInvocationCounts, toolAverageDurations, errorCounts and activeSessions |
reset() | Clears everything and sets active sessions to 0 |
You can also call recordToolInvocation, recordError, incrementActiveSessions and decrementActiveSessions yourself. recordToolInvocation ignores a null tool name.
Transport interceptor
McpTracingInterceptor(transport, tracer) is itself an McpTransport. Use it where you would use the transport: with McpClient.create, McpClient.builder().transport(...) or McpToolBridge.custom(...). This is the way to trace the MCP tools an agent calls, because the agent never sees a TracingMcpClient.
| Event | Span | Attributes |
|---|---|---|
connect() | mcp.transport.connect | — |
send(message) | mcp.transport.send | mcp.message.size (characters), mcp.method ("unknown" when the message has none) |
| incoming message | mcp.transport.receive | mcp.response.size (characters) |
disconnect() | mcp.transport.disconnect | — |
The receive span ends after the client has handled the message. The interceptor does not match responses to requests, so a request and its response are two unrelated spans. setOnError passes straight to the wrapped transport, and getDelegate() returns it.