Skip to content
tnsaiDocs
GitHub

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

TracerBehavior
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.

CallSpanAttributes
connect()mcp.connect—
initialize()mcp.initialize—
disconnect()mcp.disconnect—
listTools()mcp.listToolsmcp.tools.count
executeTool(name, args)mcp.executeToolmcp.tool.name
listResources()mcp.listResourcesmcp.resources.count
readResource(uri)mcp.readResourcemcp.resource.uri, mcp.resource.parts
listPrompts()mcp.listPromptsmcp.prompts.count
getPrompt(name, args)mcp.getPromptmcp.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.
  • executeTool records 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 example McpException:search. List, read and get calls use the operation name as the tool, such as listTools. connect, initialize and disconnect failures are not counted.
MethodReturns
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.

EventSpanAttributes
connect()mcp.transport.connect—
send(message)mcp.transport.sendmcp.message.size (characters), mcp.method ("unknown" when the message has none)
incoming messagemcp.transport.receivemcp.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.