Server Setup
Install the SDK, configure withYavio(), and understand auto-captured events
Installation
npm install @yavio/sdkThe SDK works with either MCP SDK generation as an optional peer dependency: @modelcontextprotocol/sdk >= 1.24.0, or @modelcontextprotocol/server 2.x.
Hosts that build a new McpServer per request (stateless Streamable HTTP, createMcpHandler, mcp-handler, Skybridge 2) create the client once with const analytics = createYavio(options) and call analytics.wrap(server) per request; see the package README's "Stateless / per-request servers" section. One difference to withYavio(): wrap() instruments every tool on the server, including those registered before the wrap, while withYavio() tracks only the tools registered through the proxy it returns. Wrapping the same server twice, with either entry point, returns the existing proxy: the first wrap wins and later options are ignored — any differing option (key, endpoint, capture, tools, serverOnly, intent, a sessionKey added or dropped) logs YAVIO-1108 once.
AWS Lambda, Vercel Functions, Cloudflare Workers, Netlify Functions and Cloud Run with request-based billing freeze or throttle the instance once the response is out, so events queued for the 10 s flush interval would wait until the instance is recycled and be lost. Since 0.5.5 the client has await analytics.flush() for platforms without a waitUntil (once the response body is complete, before the handler returns) and analytics.flushWhenSent(response, waitUntil) for those with one (Vercel's waitUntil, Cloudflare's ctx, Netlify's context), both bounded by flushTimeoutMs. Cloudflare Workers need nodejs_compat and a compatibility date of 2025-09-15 or later. How far each runtime has been checked: AWS Lambda in an emulation of its runtime client, Cloudflare Workers on a local workerd (not on the edge), and Vercel, Cloud Run and Netlify from their documentation only. The Vercel Edge runtime is not supported, and Deno and Bun are untested. The package README's "Serverless and edge runtimes" section has one recipe per platform.
Sessions on per-request servers
A per-request server has no session of its own: a stateless transport never issues Mcp-Session-Id, and the 2026-07-28 protocol era drops it. The SDK takes a call's session from the first key it finds, and every event records which in session_source:
| Order | Key | session_source |
|---|---|---|
| 1 | Mcp-Session-Id (a sessionful transport) | mcp_session_id |
| 2 | ChatGPT's _meta["openai/session"] | openai_session |
| 3 | VS Code / Copilot Chat's _meta["vscode.conversationId"], from any client | vscode_conversation |
| 4 | The Codex CLI's _meta.threadId, only from codex-mcp-client (envelope, initialize or User-Agent) | codex_thread |
| 5 | Your sessionKey option | custom |
| — | None: over HTTP a session of its own for that one call, on every host; on stdio the client process's | per_request / connection |
Keys 3 and 4 apply on stdio too: a VS Code stdio server gets one session per conversation since 0.5.4 instead of one per client process — more sessions, and identify() no longer carries across its conversations. Claude Code, claude.ai connectors, Cursor and Zed send no conversation key; if your host knows the conversation, return its id from sessionKey — a conversation id, never a per-user id on its own (see Configuration). A key-less HTTP call (per_request) is not counted as a session on the dashboard — the Sessions KPI says how many such calls there were — and its connection event is emitted once per process per client instead of once per call.
Wrapping your server
The withYavio() function takes your McpServer instance and returns an instrumented version. All tool registrations and connections are intercepted transparently.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { withYavio } from "@yavio/sdk";
const server = new McpServer({
name: "my-server",
version: "1.0.0",
});
const instrumented = withYavio(server, {
apiKey: "yav_...",
endpoint: "https://ingest.yavio.ai/v1/events",
capture: {
inputValues: true,
},
});Options
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | — | Project API key. Falls back to YAVIO_API_KEY env var, then .yaviorc.json. |
endpoint | string | https://ingest.yavio.ai/v1/events | Ingestion API URL. Falls back to YAVIO_ENDPOINT env var. Must be http(s)://; anything else puts the SDK in no-op mode (YAVIO-1002) rather than using the hosted default. |
capture.inputValues | boolean | true | Capture tool input values, key names and types, plus client metadata |
capture.outputValues | boolean | true | Capture tool results |
capture.geo | boolean | "region" | "country" | true | End-user location: true keeps it as sent, "region" keeps the country and the state or province, "country" keeps only the country, false drops it |
capture.rejectedInputValues | boolean | false | Store the argument values of calls rejected before the handler runs (by default only their names and types) |
tools | Record<string, ToolCaptureOverride> | — | Per-tool overrides of the capture flags (inputValues, outputValues, rejectedInputValues, intent) |
shutdownTimeoutMs | number | 4000 | Deadline for delivering queued events on shutdown, an integer in [100, 2147483647] milliseconds (anything else logs YAVIO-1006 once and uses the default). Outside a signal it bounds this client's shutdown() from its call. On SIGTERM/SIGINT the SDK fixes one deadline for the whole process at signal + D, where D is the smallest value set explicitly (option, YAVIO_SHUTDOWN_TIMEOUT_MS or .yaviorc.json) by any createYavio() client, withYavio() call or shutdown() still running when the signal arrives, else 4 s — participants that set none do not lower it, and the env var counts even when no client is live. The SDK's own drain and every shutdown() at or after the signal resolve by that deadline, however long your handler's close took before it; what is still undelivered is dropped and counted in one YAVIO-1205 line (one more when the signal was handed back before the transport stops had settled: at the deadline when a shutdown() is still waiting then, otherwise once they have settled). A SIGTERM after the SIGINT that started the drain (Claude Code's stop sequence for a stdio server) moves the deadline in to 400 ms after the SIGINT (or 300 ms after the SIGTERM, when sooner), and a higher value does not extend that (since 0.5.5). A process that ends on its own with events queued (no signal, no shutdown(): a stdio server whose client closed stdin) drains within min(1.5 s, this value): a lower value lowers that bound, a higher one does not raise it. Rule of thumb: your close cap + 1 s, both inside the platform's grace period (Fly.io 5 s, Cloud Run 10 s, Kubernetes and ECS 30 s) |
flushTimeoutMs | number | 1000 | How long createYavio().flush() and flushWhenSent() wait for ingest (past it, flushWhenSent()'s flush still waits for the batch it sent, see waitUntilTimeoutMs), an integer in [100, 2147483647] milliseconds (anything else logs YAVIO-1006 once and uses the default). Nothing is dropped at it: what is undelivered stays queued for the next flush, with one YAVIO-1208 line per stall. Keep it well inside the function's own timeout when you await the flush (AWS Lambda's default is 3 s). During a SIGTERM/SIGINT the flush ends by the process deadline when that comes first (since 0.5.5) |
waitUntilTimeoutMs | number | 10000 | How long the flush flushWhenSent() hands to waitUntil may keep waiting for the batch it sent, past flushTimeoutMs. That flush delays no response, and on Cloudflare Workers a fetch dies with its request once it ends, so an ingest slower than flushTimeoutMs needs the wait. Only the flush with a fetch of its own out waits, and no other flush takes that batch back meanwhile. Once it is answered, that flush also sends the events of concurrent requests whose flushes gave up waiting on its batch, if another round trip fits. On Vercel the function runs while it waits. Same range and YAVIO-1006 rule as flushTimeoutMs (since 0.5.5) |
sessionKey | (request) => string | undefined | — | The conversation of a call that carries no session key the SDK knows (above). Gets { headers, meta, authInfo, extra }; synchronous; at most 1 024 characters; stored as a keyed hash. A throw, a promise or a non-string counts as no key with one YAVIO-1102 warning; a non-function logs YAVIO-1006. Code only. See Configuration |
See Configuration for the full precedence chain.
Auto-captured events
Once wrapped, the SDK automatically captures these events without any additional code:
Tool calls
Every server.tool() and server.registerTool() invocation is tracked with:
- Event type:
tool_call - Tool name and input key metadata
- Latency (milliseconds, via
performance.now()) - Status:
successorerror— an error when the handler throws (error_category: unknown), returns a result withisError: true(tool_error), when the call fails before the handler runs because the tool is unknown, disabled or the arguments do not match its schema (validation, no latency), or when the result fails the tool's ownoutputSchema(server) - Error message — the exception message or the
isErrorresult's text, PII-stripped and clamped to 500 characters - Platform (auto-detected from the connecting client)
- Inputs (with
capture.inputValues, the default) — the arguments, plus the request_metaverbatim (_meta, without the trace context:baggage,tracestate,traceparent,Correlation-Context,_dd_trace_context,uberctx-…,ot-baggage-…), the MCP task id (_taskId) and four allow-listed request headers with the URL sans query (_requestInfo); the client metadata as first-class fields:locale,end_user_agent,subject_id,country_code; and a valid W3Ctraceparentasmetadata.traceparent, from the request_meta, else the HTTP header, else Datadog's_meta._dd_trace_context(SDK 0.5.5 and later; see Events) - Output (with
capture.outputValues, the default) — the tool result, binary content replaced by size placeholders
A tool on MCP SDK v2 that asks the user for input (inputRequired(), over several rounds) is one tool call too: one event from the round whose result is final, marked with metadata.input_rounds or metadata.input_retry, its latency the handler time without the user's think time (SDK 0.5.5 and later; see Events).
On ChatGPT the request _meta carries the user's approximate location including coordinates and the platform's stable per-user id, and with the defaults both are stored on every tool call. Disclose that collection in your privacy policy, or reduce it with capture.geo: "country" (country only, no city or coordinates), capture.geo: false (no location) or capture.inputValues: false (everything, also available per tool).
Connections
With the first tool call of a session (not on connect; there is no disconnect event):
- Event type:
connection - Session ID from the call's session key, else a session of the call's own over HTTP and the connection's own on stdio (see Sessions on per-request servers), with its
session_source - Platform detected from client signals
- Client name and version from the protocol envelope (MCP 2026-07-28), else the
initializehandshake, else the first product token of the request'sUser-Agent:name/versionwith a digit in the version, or a barenamewhen that is the whole User-Agent, and only when the name is a known MCP client: a vendor the platform matcher recognises (openai-mcp,Claude-User,claude-code,cursor, …) or an allowlisted token (mcp-inspector,mcp-remote). So a stateless host, whose instance never sawinitialize, still recordsopenai-mcp/1.0.0, orClaude-Userwith no version, while a runtime token such asnode, a library or server-framework token such asPython/3.12 aiohttp/3.9.5orNitro/2.9.0, a client the SDK does not know yet, a bare first word of a longer string such as theVisualofVisual Studio Code/1.95, or a version without a digit such asOpenAI/Python 1.40.0names no client and the connection stays without one;metadata.client_info_sourcesays which - Protocol version (
protocol_version) from the MCP 2026-07-28 request envelope, else the request'smcp-protocol-versionheader, else the version the server answeredinitializewith, so stdio and in-memory connections have it too (SDK 0.5.5 and later; earlier SDKs read the header only). Only the protocol's date shape (2025-06-18) is stored - Client capabilities (
metadata.client_capabilities, SDK 0.5.5 and later): the names of the capabilities the client declared, such aselicitation,rootsorsampling, never their values. From the 2026-07-28 envelope, else theinitializehandshake; sorted, at most 20. Absent when there are none, and for a 2025-era client of a per-request host, whose server instance never sawinitialize - Emitted at least once per session per process: a conversation served by several instances has one connection event per instance. A key-less HTTP call (
per_request) emits one per process per client (platform, client name and version, protocol version), not one per call, so it records the first caller's client capabilities; on an MCP SDK v1 1.24/1.25 server that reuses one stateless transport, capabilities from the handshake are the last client's to initialize, as is a client name from the handshake. Sessions are counted as distinctsession_ids, never as connection events
No-op mode
If no API key is found through any configuration source, withYavio() returns the original McpServer unchanged. No proxy is created, no HTTP requests are made, and there is zero runtime overhead.
This makes it safe to include withYavio() in your codebase unconditionally — it only activates when configured.
No-op mode emits a YAVIO-1000 debug log so you can confirm it's active during development.
Transport behavior
The SDK batches events and flushes them to the ingestion API efficiently:
- Flush interval: every 10 seconds
- Early flush: triggers at 100 buffered events
- Buffer cap: 10,000 events (oldest events are dropped when full)
- Retry: up to 5 attempts with exponential backoff (starting at 1 second)
- Request timeout: 30 seconds per HTTP request
- Shutdown: SIGTERM/SIGINT handlers drain remaining events within one process deadline (
shutdownTimeoutMs, 4 s by default, counted from the signal), print oneYAVIO-1205line with the count if the deadline gave events up, then hand the signal back: the SDK removes its listeners and, if it was the only listener when the signal arrived, re-raises the signal so the process exits the way it would without the SDK (128 + the signal number: 130 after Ctrl-C, 143 after SIGTERM). An app with its own handler keeps control; a second Ctrl-C more than 250 ms after the first ends the wait (one within 250 ms is the same keypress, whichnpm runforwards twice). ASIGTERMafter theSIGINTis a client's stop sequence — Claude Code stops a stdio server withSIGINT,SIGTERM100 ms later andSIGKILL400 ms after that — and ends the wait by 400 ms after theSIGINTinstead of at once (since 0.5.5; before, about 100 ms in), with aYAVIO-1205line that names that bound; aSIGTERMfollowed by aSIGINTstill ends it at once. A library that also waits to be the last listener (signal-exit, used by execa and ora) does not get its turn, so handle the signal yourself in that case - Natural exit: a process that ends on its own with events queued — a stdio server whose client closed its stdin (the MCP stdio binding's graceful shutdown: no signal comes), a script or CLI that never calls
shutdown(), a host that closes its server without a signal — sends them before it exits, within 1.5 s or a lowershutdownTimeoutMs(a higher one does not raise it: the MCP TypeScript clients sendSIGTERM2 s after closing stdin). What it cannot deliver in that time is counted in oneYAVIO-1205line at exit (Exit deadline of 1500 ms reached — 6 events not delivered …); with nothing queued the exit is immediate and quiet. Since 0.5.5; before, those events were lost without a line.process.exit()skips it. A stdio server with handles of its own (a database pool, a watcher) does not exit when stdin ends: create the client withshutdownTimeoutMs: 1_500, and onprocess.stdin'sendeventawait analytics.shutdown(), thenprocess.exit(0)— the README's "stdio servers" section has the recipe and what each client does - Flush after the response: on a runtime that freezes the instance once the response is out,
await analytics.flush()(orflushWhenSent(response, waitUntil)) sends what is queued at the call and waits for ingest withinflushTimeoutMs(1 s). Events queued after the call are the next flush's. A batch waiting for its retry is retried at once when its last attempt is at least a second old, once per flush, without counting towards the 5 attempts, and aRetry-Afterfrom ingest (429 or 503) is never cut short. A send an earlier request left in flight is taken back once it is older thanflushTimeoutMs(twice that each time the same batch is taken back, and at least twice ingest's last round trip) and its batch re-sent unchanged. On Cloudflare Workers such a send never answers once its request has ended. The re-sent batch may reach ingest twice: if it had reached ingest after all, ingest stores both copies: ClickHouse drops the second when it makes up an insert block of its own (the dedup token over event id and timestamp), otherwise the ReplacingMergeTree collapses the two at merge time, and until then a query withoutFINALorLIMIT 1 BY event_idcounts the event twice. Nothing is dropped at the deadline; what is left undelivered is oneYAVIO-1208line per stall, at most one a minute (a second stall within a minute of the last line is reported by the first flush after the minute if it still lasts then; one that ends within the minute prints nothing). The flush keeps the transport and sends the events of every request on the instance; concurrent flushes share the batches.shutdown()is for the end of the process, not for every request (since 0.5.5). The flushflushWhenSent()hands towaitUntilkeeps waiting pastflushTimeoutMsfor the batch it sent, up towaitUntilTimeoutMs(10 s), because it delays no response. On Cloudflare Workers a fetch dies with its request once that flush ends. Withctx.waitUntil(analytics.flush())there, setflushTimeoutMsabove ingest's worst latency. The flush interval is armed by the first event, not atcreateYavio()(Cloudflare Workers forbid timers outside a request) - stdout: the SDK writes every line to stderr, never to stdout, which is the JSON-RPC wire of a stdio server (since 0.5.5; before, the
Server-only modeandIntent capture enabledlines went to stdout) - Your own handler: stop accepting and give the requests in flight a bounded time (
closeWithin(http, 3_000)in the README:closeIdleConnections()every 100 ms,closeAllConnections()at the cap), thenawait analytics.shutdown(), then exit — onSIGTERMand onSIGINT(Fly.io sendsSIGINT), with a second signal joining the first run. It resolves once ingest has every event queued before the call — the SDK's own drain included: the SDK's listener is prepended, so it runs first and its drain is what yourshutdown()finds running — or at the deadline; a second or concurrent call waits for the first. The one order this does not cover is an app listener added withprependOnceListenerafter the SDK installed its own: it runs before the SDK's and is gone when the SDK counts listeners, so the SDK takes itself for the only one and re-raises the signal after its drain (the #140 rule); register withonoronceinstead. An unboundedhttp.close()closes the idle keep-alive sockets, but its callback waits for the socket of the request in flight at close time, which stays open after its response until the client closes it or the server's keep-alive timeout ends it (keepAliveTimeoutplus a 1 s buffer, configurable askeepAliveTimeoutBuffersince Node 22.19 and 24.6: about 6 s by default on current Node, over a minute behind some load balancers), and aConnection: closeheader set once shutdown began cannot reach an SSE response whose headers are already out, so bound the close. Next.js installs its own signal handler and exits afterserver.close()without waiting for the SDK: setNEXT_MANUAL_SIG_HANDLE=trueto use the recipe; Fastify's server isfastify.server, Nest'sapp.getHttpServer(). AwithYavio()host creates acreateYavio()client once at startup, next to itswithYavio()calls and for the same key and endpoint, uses it only forshutdown(), and awaits that in the handler (created inside the handler, after the SDK handed the signal back, it would install the SDK's listeners again and they would stay). The README's "Stateless / per-request servers" section has the full pattern
PII stripping
The SDK strips personally identifiable information (PII) from event payloads before sending:
- Email addresses
- Credit card numbers
- Social Security numbers
- Phone numbers
This runs client-side as a first line of defense. The ingestion API applies the same stripping server-side for defense in depth.