Yavio

Server Setup

Install the SDK, configure withYavio(), and understand auto-captured events

Installation

npm install @yavio/sdk

The 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:

OrderKeysession_source
1Mcp-Session-Id (a sessionful transport)mcp_session_id
2ChatGPT's _meta["openai/session"]openai_session
3VS Code / Copilot Chat's _meta["vscode.conversationId"], from any clientvscode_conversation
4The Codex CLI's _meta.threadId, only from codex-mcp-client (envelope, initialize or User-Agent)codex_thread
5Your sessionKey optioncustom
—None: over HTTP a session of its own for that one call, on every host; on stdio the client process'sper_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

OptionTypeDefaultDescription
apiKeystring—Project API key. Falls back to YAVIO_API_KEY env var, then .yaviorc.json.
endpointstringhttps://ingest.yavio.ai/v1/eventsIngestion 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.inputValuesbooleantrueCapture tool input values, key names and types, plus client metadata
capture.outputValuesbooleantrueCapture tool results
capture.geoboolean | "region" | "country"trueEnd-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.rejectedInputValuesbooleanfalseStore the argument values of calls rejected before the handler runs (by default only their names and types)
toolsRecord<string, ToolCaptureOverride>—Per-tool overrides of the capture flags (inputValues, outputValues, rejectedInputValues, intent)
shutdownTimeoutMsnumber4000Deadline 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)
flushTimeoutMsnumber1000How 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)
waitUntilTimeoutMsnumber10000How 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: success or error — an error when the handler throws (error_category: unknown), returns a result with isError: 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 own outputSchema (server)
  • Error message — the exception message or the isError result'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 _meta verbatim (_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 W3C traceparent as metadata.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 initialize handshake, else the first product token of the request's User-Agent: name/version with a digit in the version, or a bare name when 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 saw initialize, still records openai-mcp / 1.0.0, or Claude-User with no version, while a runtime token such as node, a library or server-framework token such as Python/3.12 aiohttp/3.9.5 or Nitro/2.9.0, a client the SDK does not know yet, a bare first word of a longer string such as the Visual of Visual Studio Code/1.95, or a version without a digit such as OpenAI/Python 1.40.0 names no client and the connection stays without one; metadata.client_info_source says which
  • Protocol version (protocol_version) from the MCP 2026-07-28 request envelope, else the request's mcp-protocol-version header, else the version the server answered initialize with, 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 as elicitation, roots or sampling, never their values. From the 2026-07-28 envelope, else the initialize handshake; sorted, at most 20. Absent when there are none, and for a 2025-era client of a per-request host, whose server instance never saw initialize
  • 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 distinct session_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 one YAVIO-1205 line 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, which npm run forwards twice). A SIGTERM after the SIGINT is a client's stop sequence — Claude Code stops a stdio server with SIGINT, SIGTERM 100 ms later and SIGKILL 400 ms after that — and ends the wait by 400 ms after the SIGINT instead of at once (since 0.5.5; before, about 100 ms in), with a YAVIO-1205 line that names that bound; a SIGTERM followed by a SIGINT still 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 lower shutdownTimeoutMs (a higher one does not raise it: the MCP TypeScript clients send SIGTERM 2 s after closing stdin). What it cannot deliver in that time is counted in one YAVIO-1205 line 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 with shutdownTimeoutMs: 1_500, and on process.stdin's end event await analytics.shutdown(), then process.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() (or flushWhenSent(response, waitUntil)) sends what is queued at the call and waits for ingest within flushTimeoutMs (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 a Retry-After from ingest (429 or 503) is never cut short. A send an earlier request left in flight is taken back once it is older than flushTimeoutMs (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 without FINAL or LIMIT 1 BY event_id counts the event twice. Nothing is dropped at the deadline; what is left undelivered is one YAVIO-1208 line 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 flush flushWhenSent() hands to waitUntil keeps waiting past flushTimeoutMs for the batch it sent, up to waitUntilTimeoutMs (10 s), because it delays no response. On Cloudflare Workers a fetch dies with its request once that flush ends. With ctx.waitUntil(analytics.flush()) there, set flushTimeoutMs above ingest's worst latency. The flush interval is armed by the first event, not at createYavio() (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 mode and Intent capture enabled lines 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), then await analytics.shutdown(), then exit — on SIGTERM and on SIGINT (Fly.io sends SIGINT), 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 your shutdown() 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 with prependOnceListener after 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 with on or once instead. An unbounded http.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 (keepAliveTimeout plus a 1 s buffer, configurable as keepAliveTimeoutBuffer since Node 22.19 and 24.6: about 6 s by default on current Node, over a minute behind some load balancers), and a Connection: close header 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 after server.close() without waiting for the SDK: set NEXT_MANUAL_SIG_HANDLE=true to use the recipe; Fastify's server is fastify.server, Nest's app.getHttpServer(). A withYavio() host creates a createYavio() client once at startup, next to its withYavio() calls and for the same key and endpoint, uses it only for shutdown(), 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.

On this page