Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Observability patterns

Audience:engineerarchitectMCP spec 2026-07-28

Deprecation notice: the protocol’s logging capability is deprecated as of 2026-07-28 (SEP-2577), with a window of at least twelve months, and logging/setLevel is removed outright (SEP-2575). The suggested migration is exactly what this page already teaches: stderr and platform runtime logs for records, OpenTelemetry for traces. The per-request io.modelcontextprotocol/logLevel details are covered in the structured-logging section below. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.

When something goes wrong in an MCP system, the failure rarely announces itself: a tools/call quietly returns an error result, a response stream breaks mid-request and the result is simply gone (the 2026-07-28 revision removed SSE redelivery, so the client’s only move is to re-issue), a request that worked yesterday fails today on a fresh instance no one can reproduce. On Vercel you cannot attach to the process, because there is no process to attach to; your server is a stream of invocations across instances that come and go. Diagnosing it means being able to see the messages, time them, and correlate them across tiers and across invocations. MCP gives you natural seams (every message carries an id, a method, and a direction), and Vercel gives you the sinks: runtime logs per invocation, drains to forward them, and OpenTelemetry for traces. The same output-minimization discipline that keeps a server secure keeps its logs from leaking; that thread runs through this whole page.

Structured logging

Anything a Vercel Function writes to standard output or standard error becomes a runtime log entry, captured and grouped per request: console.log lands as info, console.error as error. Build on that with one structured JSON record per JSON-RPC frame, with a stable shape: id, method, direction (inbound or outbound), and elapsed time. With protocol sessions removed in 2026-07-28, the durable join keys are the JSON-RPC id within an exchange, any server-minted handle your tools issue across exchanges, and trace context (next section); Vercel adds its own requestId and invocationId fields in the Logs tab, which join your protocol-level records to the invocation that produced them, its duration, and whether it started cold.

The limits are real and worth designing for: 256 log lines per request, 256 KB per line, 1 MB per request, and retention of 1 hour on Hobby, 1 day on Pro, and 3 days on Enterprise (30 days with Observability Plus). Two consequences: log compact single-line JSON rather than pretty-printed blobs, and treat the dashboard as a debugging window, not an archive. The archive is a drain (next section).

Two MCP-specific rules:

  • On stdio, log to stderr, never stdout. In local stdio development a server’s stdout carries only MCP messages; one stray log line corrupts the framing and the client sees a dead server. Deployed over Streamable HTTP this hazard disappears, which is one more small way the transport fits the platform. Under the deprecation this is also the destination of record: stderr is the migration target for server-side logging.
  • Protocol logging is now per-request and opt-in. During the deprecation window a server that declares the logging capability can still emit structured records to the client via notifications/message (with level, logger, and data), but the client-set verbosity floor is gone with logging/setLevel: as of 2026-07-28 the level rides each request’s _meta under io.modelcontextprotocol/logLevel, and servers MUST NOT emit notifications/message for requests that did not include it. Treat that as a design gift on serverless: log verbosity becomes request-scoped configuration, which is the only kind a stateless function can honor. The spec is blunt about content either way: log messages MUST NOT contain credentials, secrets, or personal information.

Drains: getting telemetry out

Runtime logs answer “what just happened”; drains answer everything older than your retention window. A drain (available on Pro and Enterprise plans) forwards observability data to an external HTTPS endpoint or a native integration, one data type per drain. Six types exist: Logs (runtime, build, and static), Traces (OpenTelemetry format), Speed Insights, Web Analytics, Connect, and Audit Logs (Enterprise only); Logs and Traces are the two an MCP server cares about. Point them at your log pipeline and the Hobby-tier hour stops being your incident-response memory.

The receiving end is part of your attack surface, so secure it like one:

  • Verify the signature. Vercel sends an x-vercel-signature header, an HMAC-SHA1 of the raw body keyed with the drain’s secret; recompute it and compare in constant time before trusting a payload, or anyone who discovers the endpoint URL can feed fabricated records into your pipeline and your alerting.
  • The drain endpoint inherits your logs’ sensitivity. Whatever your functions log, the drain destination now stores; your redaction posture (below) travels with the data, and a third-party destination widens the audience for every mistake. Vercel can hide client IP addresses in drains team-wide; decide deliberately whether you need them.

See Deployment for where drains fit in project setup.

Tracing across tiers

A single user action fans out: host to client to server to backend, with the middle hop crossing a trust boundary on the public internet. W3C Trace Context is the standard way to follow it: a traceparent HTTP header carrying the trace id, the parent span id, and a sampling decision. Streamable HTTP makes this free in a way stdio never was: every MCP message is an HTTP request, so trace context rides ordinary headers with no protocol invention, from the host’s outbound tools/call POST through your function and on to its downstream calls. The 2026-07-28 revision also standardizes the in-message form (SEP-414): _meta keys named traceparent, tracestate, and baggage carry the same W3C values inside the JSON-RPC message itself, which covers stdio and any intermediary that would drop unfamiliar headers. On this stack prefer the HTTP header and treat the _meta convention as the portable fallback; if both appear, they should agree.

POST tools/calltraceparenttraceparentHost + clientServer functionBackendRuntime logsTrace drain / OTel
POST tools/calltraceparenttraceparentHost + clientServer functionBackendRuntime logsTrace drain / OTel
Mermaid flowchartOpen in Mermaid Live Editor
Diagram source (Mermaid)
flowchart LR
    host[Host + client] -->|"POST tools/call<br/>traceparent"| fn[Server function]
    fn -->|"traceparent"| backend[(Backend)]
    fn --> logs[Runtime logs]
    fn --> drain[Trace drain / OTel]

On Vercel the wiring is @vercel/otel: an instrumentation.ts at the project root whose register() calls registerOTel({ serviceName }). Next.js propagates inbound trace context automatically; outbound propagation is opt-in per destination via instrumentationConfig.fetch.propagateContextUrls, with dontPropagateContextUrls as the explicit deny list. Spans leave the platform through a trace drain or your OTel backend’s integration.

Two behaviors that bite:

  • Sampling is an AND gate. For a span to be emitted, the inbound traceparent sampling decision (if present) and Vercel’s own sampling rules must both say yes. If your traces vanish, check the caller’s sampler before your own config: an upstream not-sampled decision darkens the whole path.
  • Propagation is a trust decision. traceparent seems harmless, but consistent ids handed to a third-party MCP server are correlation handles across your users’ requests, and a compromised destination can lie in whatever spans it exports. Propagate to backends you operate; put everything else in dontPropagateContextUrls, and apply the same judgment to the _meta trace keys, which no URL deny list will catch for you. The boundary rule from trust boundaries applies to telemetry too: a trace may follow a request into a server, but no server should see another server’s spans, the host’s transcript, or context from a different conversation.

Within an exchange, the JSON-RPC id remains the protocol-native join key; log it as a span attribute so wire-level records and traces line up.

Metrics for tool invocations

Aggregate signals tell you what no single log line can:

  • Per-tool invocation count, latency distribution, and error rate, with error rate keyed off isError results. A tool returning isError: true is a tool execution failure, distinct from a JSON-RPC protocol error; count them separately, and dimension per tool, per server, and (for multi-tenant servers) per principal from the verified token, never from arguments (see identity and principals).
  • Cold and warm, split. The slowest request you serve is the first one on a fresh instance after a deploy or an idle reclaim; averaged into warm tools/call latency it disappears. Vercel’s function metrics expose cold start counts; keep the dimension.
  • Duration against maxDuration. A call that hits the ceiling returns nothing at all, so you want to watch the distribution approach the cliff, not learn about it from timeouts. A p95 drifting toward the limit is the signal to reshape the tool as an async job.
  • In-flight requests, cancellation rate, and notification volume - the core operational trio from the internals debugging notes.

Where to compute them: the cheapest robust path is a custom OTel span per tools/call carrying tool, isError, and principal attributes, aggregated wherever your traces land; drains feed the same data to a metrics pipeline if you prefer logs-to-metrics. Vercel’s dashboard gives you per-function duration, memory, and cold starts, but the platform does not know what a tool is: the per-tool dimension only exists if your code emits it. One edge-side assist arrives with 2026-07-28: the required Mcp-Method and Mcp-Name headers on every Streamable HTTP POST mean the platform’s own request logs and Firewall metrics can slice by method and tool name without touching the body.

Redaction

Observability and least privilege pull in the same direction: log enough to diagnose, never more than the caller is entitled to. Do not log full tool arguments or results by default; arguments carry PII and secrets, and tool outputs can carry prompt-injection payloads you do not want replayed into a log scraper, an alerting summary, or a downstream model. Log the tool name, the principal, the decision, the shape (sizes, counts, status), and a correlation id; log the payload only behind a deliberate debug flag that is off in production.

The mechanical pattern: a redact helper that masks a value to **** plus a short suffix, and only when the value is long enough that the suffix identifies without revealing. examples/facade-server (in the repository) shows the posture end to end: an audit log that records which backend and which scope, and never keys or results.

Serverless sharpens three edges:

  • Every log line has an audience. Runtime logs are visible to the whole team in the dashboard, and drains forward them to external services; a secret logged once fans out to every destination downstream. The spec’s MUST NOT list for notifications/message (credentials, secrets, personal information) is the right bar for your platform logs too.
  • Never log the token. With withMcpAuth every request arrives with a bearer token; the Authorization header, and everything on AuthInfo beyond the principal id and scopes, stays out of every record. See Authentication.
  • Concurrency breaks ambient context. With Fluid compute running concurrent requests in one instance, a module-level “current user” interleaves principals in your audit trail, which corrupts precisely the record you would need in an incident. Carry request context explicitly; serverless sessions covers why. See Monitoring & audit.

An audit trail you cannot trust is overhead; one you can trust is a control.

Bibliography