# 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

<!-- java-contract: src/main/java/com/example/tnsai/docs/McpObservabilityExample.java -->
```java
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.
- `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.

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

## Related

- [MCP client](client.md)
- [Transports](transports.md)
- [Test MCP servers](testing.md)
- [Observability](../observability/monitor.md)
