Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Observability patterns
Deprecation notice: the protocol’s
loggingcapability is deprecated as of 2026-07-28 (SEP-2577), with a window of at least twelve months, andlogging/setLevelis 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-requestio.modelcontextprotocol/logLeveldetails are covered in the structured-logging section below. Wire status: the pinned stack (mcp-handler2.1.1 on@modelcontextprotocol/server2.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 SDKClientdefaults 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
stdoutcarries 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
loggingcapability can still emit structured records to the client vianotifications/message(withlevel,logger, anddata), but the client-set verbosity floor is gone withlogging/setLevel: as of 2026-07-28 the level rides each request’s_metaunderio.modelcontextprotocol/logLevel, and servers MUST NOT emitnotifications/messagefor 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-signatureheader, 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.
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
traceparentsampling 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.
traceparentseems 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 indontPropagateContextUrls, and apply the same judgment to the_metatrace 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
isErrorresults. A tool returningisError: trueis 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/calllatency 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
withMcpAuthevery request arrives with a bearer token; theAuthorizationheader, and everything onAuthInfobeyond 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.
Related
- MCP internals overview - the symptom-to-cause table your logs and metrics exist to answer
- Serverless sessions - cold starts, instance reuse, and the concurrency hazard behind the redaction rules
- Deployment - configuring drains and the rest of the project-level observability hookup
- Security checklist: Monitoring & audit - the audit controls to verify before shipping
- Tool result rendering - why tool output is untrusted in logs, not just in UIs
examples/async-jobs-server(in the repository) - progress notifications as live observability of long-running workexamples/secure-tools-server(in the repository) - output minimization as a logging discipline
Bibliography
- Model Context Protocol Specification, Logging, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging
- Model Context Protocol Specification, Tools, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- Model Context Protocol Specification, Deprecated Features, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/deprecated
- Model Context Protocol Specification, Changelog, version 2026-07-28 (per-request log level,
_metatrace-context conventions, required MCP headers) - https://modelcontextprotocol.io/specification/2026-07-28/changelog - Vercel Documentation, Runtime Logs - https://vercel.com/docs/logs/runtime
- Vercel Documentation, Working with Drains - https://vercel.com/docs/drains
- Vercel Documentation, Drains Security - https://vercel.com/docs/drains/security
- Vercel Documentation, Instrumentation (OpenTelemetry tracing) - https://vercel.com/docs/tracing/instrumentation
- W3C, Trace Context - https://www.w3.org/TR/trace-context/