Skip to main content

MCP Observability and Auditing

This guide explains how to design observability for MCP servers and clients in light of the deprecation of the Logging feature. The 2026-07-28 revision deprecates protocol-level log notifications and brings trace propagation and HTTP-header request identification into the standard. Here we cover how to combine them with existing logging infrastructure, OpenTelemetry, and gateways. For general implementation in Go, see Logging and Observability; for the parts related to authorization auditing, see MCP Security Best Practices - Attacks and Mitigations.

1. What This Document Covers​

  • The deprecation of the Logging feature
  • Trace propagation
  • Metrics
  • Audit logs
  • Handling sensitive information
  • Rate limiting and anomaly detection
  • Visualizing token usage
  • Classifying errors

The deprecation of the Logging feature​

MCP's Logging feature (notifications/message) is deprecated in the 2026-07-28 revision. Under the feature lifecycle policy, a deprecated feature stays in the specification for at least 12 months after the release of this revision, after which it becomes eligible for removal. New implementations should not adopt it, and existing implementations are expected to migrate as follows.

  • stdio servers: write logs to stderr. The host application captures them
  • HTTP servers: stderr is not captured by the client, so send logs to your usual logging infrastructure or to OpenTelemetry

Note the behavior defined by the specification during the deprecation period. A client asks for logs per request by attaching io.modelcontextprotocol/logLevel (the eight RFC 5424 levels, debug through emergency) to the request's _meta. A server must not send notifications/message for a request that lacks this field. When it is present, the server can send them on that request's response stream, before the final response. They are not delivered on a subscriptions/listen stream. logging/setLevel and session-wide log levels do not exist in the modern era.

In other words, "stream server logs to the client's screen" has retreated to an auxiliary facility driven by a user's request, and operational observation is left to ordinary means outside the protocol. Design server-side observability from the start around structured logs, metrics, and traces, without depending on protocol log notifications.

Trace propagation​

The convention for connecting distributed tracing to MCP requests was brought into the specification by SEP-414. You carry the following keys in the request's _meta, in W3C Trace Context and W3C Baggage formats.

KeyFormat
traceparentW3C Trace Context
tracestateW3C Trace Context
baggageW3C Baggage

Other _meta keys require a reverse-DNS prefix, but these three keys are reserved as an exception, with no prefix, for compatibility with existing implementations and OpenTelemetry's semantic conventions. If you add a namespace such as io.modelcontextprotocol.traceparent, correlation between traces and logs breaks.

{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "New York" },
"_meta": {
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
}
}
}

The server creates a span with the received traceparent as its parent and carries it into downstream API calls. This lets you follow the agent's processing in the host application, the MCP server, and the downstream API as a single trace. Trace context can include correlation IDs, so follow the data-handling guidance appropriate to your environment.

The carrier is _meta rather than an HTTP header because the same method then works on every transport, including stdio.

Metrics​

In Streamable HTTP, every POST carries headers that identify the kind of request.

HeaderValueAttached to
Mcp-MethodThe JSON-RPC methodAll requests
Mcp-Nameparams.name or params.uritools/call, resources/read, prompts/get

Load balancers, gateways, and observability platforms can aggregate call counts, latency, and error rates per tool without parsing the JSON body. If the server parses the body to process the request, it verifies that headers and body agree, and rejects a mismatch with 400 and -32020 (HeaderMismatch). This stops a gap between a gateway that decides on headers and a server that executes on the body from being exploited.

Keep the following in mind when aggregating.

  • Limit label values to Mcp-Method and tool and prompt names. For resources/read, Mcp-Name is a resource URI with unbounded distinct values (high cardinality), so do not use it as a metric label as is
  • When the value of Mcp-Name cannot be safely represented as ASCII, it arrives in the form =?base64?...?=. Decode it before aggregating
  • Candidate metrics: call counts per method and tool, latency (histogram), error rate (split into protocol errors, tool execution errors, and authorization errors), in-flight requests, and the number of subscriptions/listen streams
  • Header-only aggregation can be done at the gateway. Metrics inside the application (downstream API latency, cache hit rate) are measured in the server's code

When you want to use a tool argument as a metric or routing key (for example, tenant or region), you can use x-mcp-header in the tool's inputSchema to mirror the argument into an Mcp-Param-{name} header. Headers are visible to network intermediaries, so do not apply it to passwords, API keys, tokens, or personal information.

Audit logs​

An audit log is a record that lets you trace after the fact who executed what, from which client, with what authority, and what the outcome was. An MCP server records the following as structured logs.

FieldContentSource
UserIdentifier of the authenticated userA claim of the validated access token (such as sub)
ClientThe OAuth client IDA token claim
OperationMethod and tool nameMcp-Method, Mcp-Name, body
ArgumentsThe arguments at execution (after masking)Body
ScopesScopes granted and scopes requiredToken, authorization decision
ResultSuccess, tool execution error, denialThe result's isError, HTTP status
Time and durationStart time, latencyServer
Correlation IDTrace ID, request IDtraceparent

Take the user and client from the validated token. io.modelcontextprotocol/clientInfo in the request's _meta is self-reported by the sender and is not verified by the protocol. You can use it for display, logging, and debugging, but never as the basis for security decisions or auditing.

Record scope elevation in particular. The security best practices list, as server guidance, that you should "log elevation events (scope requested, granted subset) with correlation IDs." In a design that starts with minimal scopes and steps up through WWW-Authenticate scope when a privileged operation is first attempted, the chain of "which operation triggered which scope addition, and did the user allow it" is the heart of auditing. Attach the same correlation ID to the series of 401 / 403 responses and token reacquisitions so you can trace them later.

Because MCP is now stateless, the server has no "session ID." To group requests, use the trace ID and explicit handles issued by your application (such as a workflow ID) as the correlation keys.

Handling sensitive information​

Arguments and results can contain personal and secret information. If you keep them in full in logs, the logging infrastructure becomes a new source of leaks.

  • Decide the recording policy per tool: do not record arguments and results, hash them, record selected fields, or record in full
  • Before recording, mask known secret patterns (tokens, API keys, passwords, credit card numbers) and fields that the tool's schema declares sensitive
  • Never record access tokens or the Authorization header
  • The specification says log messages must not contain credentials, personally identifying information, or internal system details that could aid attacks (this is about protocol log notifications, but the same policy applies to operational logs)
  • Control access to audit logs and set a retention period. Because the point of an audit log is to keep it, store it separately from application debug logs
  • Do not put sensitive arguments in headers with x-mcp-header. Headers tend to remain in gateway and CDN access logs

Rate limiting and anomaly detection​

With Mcp-Method and Mcp-Name attached, gateways and WAFs can apply policy per method and per tool without parsing the JSON body.

  • Call-rate limits: per user (the token's sub), client, and tool, stricter for write operations and costly tools
  • Per-tool control: allow dangerous tools (delete, transfer, send externally) only with a specific scope or from a specific network
  • Anomaly detection: spikes in calls to a particular tool, consecutive calls to tools that are not normally used, sudden rises in error rate, calls to tool names not in the list (enumeration attempts), and repeated scope step-ups in a short time
  • A gateway that decides on headers confirms that the MCP-Protocol-Version header is a version that requires header and body validation. For an older version, or a request without the header, it rejects the request rather than trusting unvalidated headers

The specification requires servers to rate limit tool invocations. Use limits at the gateway and limits inside the server together, not one or the other.

Visualizing token usage​

In MCP, tool definitions and results consume the model's context (The Context Bloat Problem and Mitigations). In operation, you cannot tell how effective progressive discovery and tool narrowing are unless you measure this.

  • Size of tool definitions: the whole tools/list result, and the character count per tool (a proxy for tokens). Record the change at each server release
  • Size of results: the distribution of the size of content and structuredContent per tool. The top tools are candidates for trimming their results
  • Usage: tools that are in the definitions but rarely called. Candidates for removal or for moving to progressive discovery
  • Client-side metrics: the model's input and output token counts are held by the host application or the model API. The server measures up to the size it returned, and matches the model token counts against the client's records by trace ID

tools/list carries the cache hints ttlMs and cacheScope, and a deterministic order is recommended. A stable order makes both the client cache and the model's prompt cache more effective. Look at the size of tool definitions and the cache hit rate side by side.

Classifying errors​

MCP mixes errors of different natures. If you fold them into one "error rate," the causes become invisible, so aggregate them separately.

ClassHow to tellExamplesMain response
Protocol errorA JSON-RPC error. HTTP is mostly 400 or 404-32602 (invalid arguments, unknown tool), -32601, -32020 HeaderMismatch, -32021 MissingRequiredClientCapability, -32022 UnsupportedProtocolVersionA client implementation or version problem. Look at the breakdown by version
Tool execution errorisError: true in the result (a success as JSON-RPC)API failures, input validation errors, business logic errorsIs the wording one the model can use to self-correct? Is it a downstream failure?
Authorization errorHTTP 401, 403Expired token, insufficient scope, audience mismatchIs the step-up flow working? Are there suspicious repeats?
Internal error-32603, HTTP 5xxA server bugA target for the SLO

isError: true is returned with HTTP 200, so in a gateway metric that looks only at HTTP status, a tool execution error looks like a success. Count isError separately in the server's application metrics.

The count of -32022 (UnsupportedProtocolVersion) is an indicator of migration progress. In an operation that accepts both legacy and modern eras at once, split request counts by protocol version and monitor how many older clients remain (Deploying and Operating MCP Servers).

2. What to Observe and How​

TargetMeansNotes
Request tracesOpenTelemetry + the trace context in _metaTrack consistently from the client to the downstream API
Per-tool metricsMcp-Method / Mcp-Name headersAggregate without parsing the JSON body
Application logsstderr / existing logging infrastructurestdout is reserved for the protocol
AuditingStructured logs + correlation IDTrace after the fact who executed what
Token costMeasure the size of tool definitions and resultsUse it to decide whether to introduce progressive discovery

3. Common Pitfalls for Developers​

Trying to stream operational logs with notifications/message​

Protocol log notifications are deprecated, and can be sent only for requests where the client asked through _meta. Send operational logs to stderr (stdio) or to your logging infrastructure and OpenTelemetry (HTTP).

Writing logs to stdout in a stdio server​

stdout is reserved for protocol messages. Mixing in logs leaves the client unable to parse the messages. Always write to stderr.

Adding a namespace to traceparent​

Other _meta keys need a reverse-DNS prefix, but traceparent, tracestate, and baggage are reserved as names with no prefix. If you add a namespace, other implementations and gateways cannot continue the trace.

Using Mcp-Name directly as a metric label​

For resources/read it holds a URI, so the number of distinct values explodes. Use as labels only the names from tools/call and prompts/get, after decoding.

Using clientInfo as the basis for auditing​

io.modelcontextprotocol/clientInfo is self-reported. Take who executed an operation from the claims of the validated token.

Keeping arguments and results in full in the logs​

It is convenient while debugging, but it turns the logs into a store for personal information and tokens. Decide the recording policy per tool and run masking.

Looking at isError: true only through HTTP metrics​

Tool execution errors come back as 200. Count them separately in application metrics, and view them apart from protocol errors and authorization errors.

Relying on a "session ID" as the correlation key​

The modern era has no sessions. Correlate with the trace ID and explicit application handles.

4. Code Examples​

Connecting the received trace context to downstream API calls (Go)​

Extract traceparent from _meta, restore it into an OpenTelemetry context, and create a span.

package mcpobs

import (
"context"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)

var propagator = propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)

// ExtractTrace restores the trace context from the request's _meta.
// meta is params._meta read as a map[string]string (non-string values are ignored).
func ExtractTrace(ctx context.Context, meta map[string]string) context.Context {
carrier := propagation.MapCarrier{}
for _, k := range []string{"traceparent", "tracestate", "baggage"} {
if v, ok := meta[k]; ok {
carrier[k] = v
}
}
return propagator.Extract(ctx, carrier)
}

// StartToolSpan starts a span for a tool call.
func StartToolSpan(ctx context.Context, tool string) (context.Context, trace.Span) {
tracer := otel.Tracer("mcp-server")
return tracer.Start(ctx, "tools/call "+tool,
trace.WithSpanKind(trace.SpanKindServer))
}

If you instrument the downstream HTTP client with something like otelhttp, the span created here becomes the parent, and the path from the client to the downstream API is connected as one trace. If you implement a client, do the reverse: write the current span's context into _meta with propagator.Inject.

Middleware that aggregates per tool from headers (Go)​

Without reading the JSON body, record the count and latency per method and tool from Mcp-Method and Mcp-Name.

package mcpobs

import (
"encoding/base64"
"log/slog"
"net/http"
"strings"
"time"
)

type statusRecorder struct {
http.ResponseWriter
status int
}

func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}

// decodeHeader decodes a value in the =?base64?...?= form.
func decodeHeader(v string) string {
if strings.HasPrefix(v, "=?base64?") && strings.HasSuffix(v, "?=") {
raw := strings.TrimSuffix(strings.TrimPrefix(v, "=?base64?"), "?=")
if b, err := base64.StdEncoding.DecodeString(raw); err == nil {
return string(b)
}
}
return v
}

// Metrics records per-tool metrics from Mcp-Method and Mcp-Name.
func Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)

method := r.Header.Get("Mcp-Method")
name := ""
// For resources/read, Mcp-Name is a URI with high cardinality, so it is not used as a label
if method == "tools/call" || method == "prompts/get" {
name = decodeHeader(r.Header.Get("Mcp-Name"))
}
slog.Info("mcp_request",
"mcp_method", method,
"mcp_name", name,
"protocol_version", r.Header.Get("MCP-Protocol-Version"),
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}

In practice, instead of slog, update the counters and histograms of the metrics library you use (Prometheus or OpenTelemetry Metrics). When you stream responses over SSE, the wrapper must delegate Flush so that the http.ResponseWriter's Flush is not lost.

A single audit log line (JSON)​

{
"ts": "2026-10-13T05:21:08.412Z",
"event": "tool_call",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"user": "00u1abcd2EFGH",
"client_id": "https://client.example.com/oauth/metadata.json",
"mcp_method": "tools/call",
"tool": "create_issue",
"args": { "repo": "acme/app", "title": "[masked]" },
"scopes_granted": ["issues:read", "issues:write"],
"result": "ok",
"is_error": false,
"duration_ms": 184
}

Record scope elevation as a separate event, with the same trace_id or a correlation ID.

{
"ts": "2026-10-13T05:21:07.980Z",
"event": "scope_elevation",
"correlation_id": "c0a8f1e2-7d3b-4a55-9a58-2a1f5d1b7e90",
"user": "00u1abcd2EFGH",
"client_id": "https://client.example.com/oauth/metadata.json",
"trigger_tool": "create_issue",
"scope_requested": ["issues:write"],
"scope_granted": ["issues:write"],
"result": "granted"
}

5. Summary​

  • The Logging feature (notifications/message) is deprecated in the 2026-07-28 revision. It can be sent only when the request's _meta carries io.modelcontextprotocol/logLevel, and operational logs go to stderr (stdio) or to logging infrastructure and OpenTelemetry (HTTP)
  • Traces propagate through traceparent, tracestate, and baggage in _meta. These three alone are reserved keys with no prefix, and they connect one trace from the client to the downstream API
  • The Mcp-Method and Mcp-Name headers let you aggregate per-tool metrics without parsing the body. Do not use Mcp-Name of resources/read, which holds a URI, as a label. The server verifies that headers and body agree
  • For audit logs, take the user and client from the validated token and do not rely on self-reported clientInfo. Record scope elevation with a correlation ID. Because the modern era has no sessions, correlate with the trace ID and explicit handles
  • For arguments and results, decide the recording policy per tool and mask them. Do not record access tokens. Do not use x-mcp-header for sensitive arguments
  • Apply rate limiting and anomaly detection at the gateway using headers, per method and tool, and layer it with limits inside the server. A gateway trusts only requests of versions that require header validation
  • Measure token usage by the size of tool definitions and results, and confirm the effect of your measures against the context problem
  • Aggregate errors as protocol errors, tool execution errors (isError), authorization errors, and internal errors. isError returns with HTTP 200, so count it separately in application metrics

References​