OtisDocs

Integration guides

MCP servers

Instrument an MCP server so calling agents can record their intent and file actionable feedback on your tools.

@runotis/sdk/mcp instruments an MCP server with one call. It adds two things to every tool, both optional and non-breaking:

  1. Intent capture — an optional _otis block in each tool's input schema where the calling agent records what it is trying to do.
  2. Tool feedback — every tool result carries a short ticket, and an injected otis_feedback tool lets the agent report concrete improvements to your tools (a misleading description, an ambiguous input, a missing field in the output). Feedback shows up in your Otis dashboard against the exact call it refers to.

It works with the official @modelcontextprotocol/sdk, @mastra/mcp, Vercel's mcp-handler, and hand-rolled servers, on both long-running and serverless runtimes.

Install

npm install @runotis/sdk

zod and your MCP server framework are optional peers — the SDK uses whatever you already have. Initialize Otis once at startup, as in the Node.js or Serverless guides:

import { initOtis } from "@runotis/sdk";

initOtis({ apiKey: process.env.OTIS_API_KEY, serviceName: "my-mcp-server" });

No API key set? Instrumentation is a safe no-op. If OTIS_API_KEY is missing, initOtis disables itself (one startup warning, no crash) and instrumentMcpServer leaves your server exactly as written — no _otis block, no otis_feedback tool, no tickets, no spans, no network calls, no per-call logs. Your tools behave as if uninstrumented. So it is safe to add instrumentation before the key is provisioned.

Quick start

Call instrumentMcpServer on your server, immediately after constructing it and before you register any tools. Pass your zod so the SDK can extend zod-based input schemas with a version-matched block.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";

const server = new McpServer({ name: "my-server", version: "1.0.0" });

instrumentMcpServer(server, { zod: z }); // ← before registerTool, not after

server.registerTool(
  "search_docs",
  { description: "Search the docs", inputSchema: { query: z.string() } },
  async ({ query }) => ({ content: [{ type: "text", text: await search(query) }] }),
);

That is the whole integration. instrumentMcpServer resolves the Otis instance you created with initOtis() automatically; pass { otis } if you manage the instance yourself.

On the official McpServer, tools registered before the instrumentMcpServer call are not instrumented — no intent, no feedback ticket. The SDK logs a warning if it detects this, but the call still succeeds, so put it right after new McpServer(...). (This ordering rule is specific to the official McpServer; @mastra/mcp and low-level servers are instrumented after construction — see their sections below.)

The _otis input block

Each instrumented tool gains one optional, namespaced field. The calling agent fills it in; you never see it (it is stripped before your handler runs):

{
  "query": "billing webhooks",
  "_otis": {
    "task_intent": "find the webhook signature header name",
    "session_intent": "wire up Stripe webhooks end to end",
    "chat_id": "conv_7fa3"
  }
}
  • task_intent — why the agent is calling this tool right now.
  • session_intent — the user's broader goal. The most recent value wins.
  • chat_id — a stable id for the current conversation/thread. When the agent sends the same value on every call in a conversation, Otis groups those calls into one session (one narrative per conversation) instead of blending every conversation a long-lived server connection happens to carry. See Sessions.
  • session_id — optional: the agent's own session id, for stricter cross-call correlation. Usually unnecessary — chat_id alone is enough.

All four are optional. Your tool keeps working unchanged whether or not the agent provides them. Capture is opportunistic: agents that recognize the _otis block fill it in, agents that don't simply omit it — so treat these as bonus signals, not something present on every call.

Tool feedback

Every tool result ends with a compact ticket:

⟦otis:tkt_a1b2c3d4e5f6a7b8⟧

The injected otis_feedback tool lets the agent turn friction into a report tied to that ticket:

{
  "ticket": "tkt_a1b2c3d4e5f6a7b8",
  "category": "output",
  "severity": "friction",
  "comment": "return the document id so I can fetch it without a second search",
  "suggested_change": "add an `id` field to each result"
}

The full instruction for when and how to give feedback lives in the otis_feedback tool's own description, so it costs the agent nothing on a normal call. The ticket is also available on the result's _meta.otis.ticket for non-text clients.

Feedback inherits the session of the tool call it rates, including the conversation fold from that call's chat_id — so the report joins the same conversation's narrative as the tool call it refers to, not the blended base session.

On a server with only one tool, the feedback tool is suppressed by default so it does not dominate the surface. Set feedback: true to force it, or feedbackMinTools to change the threshold.

Errors and failures

A tool fails in one of two ways, and both are recorded the same way on the mcp.tool.<name> span — no extra wiring:

  • Throwing — the handler rejects/throws. The exception is attached to the span and the status is set to ERROR.
  • In-band error — the handler resolves with isError: true (the MCP convention for a tool-level failure, e.g. an upstream 503). The SDK detects the flag, sets the span status to ERROR, sets an error attribute, and records the failure cause.

In both cases the span carries:

WhatWhere
Failure cause (text)exception.message event attribute — joined from the result's content text blocks, scrubbed for PII
Error typeexception.type — taken from structuredContent.name (or .error) when present, else McpToolError
Error codemcp.tool.error_code — taken from structuredContent.code when present

To get a useful type and code rather than just a generic flag, return them on structuredContent:

{
  "isError": true,
  "content": [{ "type": "text", "text": "upstream 503: service unavailable" }],
  "structuredContent": { "name": "UpstreamError", "code": "E_503" }
}

Failures roll up into the task outcome: a tool error drives the task to struggled in analytics, the same as explicit negative feedback. The host result is always passed through untouched — instrumentation never changes what your tool returns to the caller.

Tool structures

instrumentMcpServer handles the common shapes. Pick the snippet that matches your server.

Official McpServer.registerTool

const server = new McpServer({ name: "s", version: "1.0.0" });
instrumentMcpServer(server, { zod: z });

server.registerTool(
  "get_weather",
  { description: "Weather for a city", inputSchema: { city: z.string() } },
  async ({ city }) => ({ content: [{ type: "text", text: await weather(city) }] }),
);

Official legacy server.tool(...)

instrumentMcpServer(server, { zod: z });

server.tool("echo", "Echo back", { text: z.string() }, async ({ text }) => ({
  content: [{ type: "text", text }],
}));

Tools with a JSON-schema input (no zod)

If your tool's inputSchema is a JSON Schema literal rather than zod, you do not need to pass zod — the SDK injects the block as JSON Schema:

instrumentMcpServer(server); // no zod needed for JSON-schema tools

Tools with an outputSchema

Structured-output tools work unchanged. The ticket rides in _meta and as an extra content block; your structuredContent and outputSchema are never touched.

server.registerTool(
  "lookup",
  {
    description: "Look up a record",
    inputSchema: { id: z.string() },
    outputSchema: { name: z.string() },
  },
  async ({ id }) => {
    const name = await lookup(id);
    return { content: [{ type: "text", text: name }], structuredContent: { name } };
  },
);

@mastra/mcp MCPServer

Mastra registers tools in the constructor (there is no registerTool), so here you instrument the already-constructed server — the rule is before you start it, not before tools exist. Pass the MCPServer instance itself, not its internal .server; the SDK reaches the wire handlers for you, so structured output and the rest of mastra's behavior are preserved. Use instrumentMcpServer, not instrumentTools — your tools live inside an MCPServer.

import { MCPServer } from "@mastra/mcp";
import { createTool } from "@mastra/core/tools";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";

const server = new MCPServer({
  id: "my-server",
  name: "my-server",
  version: "1.0.0",
  tools: {
    add: createTool({
      id: "add",
      description: "Add two numbers",
      inputSchema: z.object({ a: z.number(), b: z.number() }),
      // Tools return mastra's normal arbitrary shape — the SDK serializes it
      // and attaches the ticket without you changing any return values.
      execute: async ({ a, b }) => ({ sum: a + b }),
    }),
  },
});

instrumentMcpServer(server, { zod: z }); // mastra tools are zod-based, so zod is required

await server.startStdio(); // or startSSE(...) / startHTTP(...) — instrument before this

Vercel mcp-handler (serverless)

Instrument as the first line of the builder callback. On serverless, set flush: "await" so each call's spans are exported before the function freezes (no waitUntil needed — see Serverless flush). For user attribution, put your stable user id on authInfo.extra.userId in your withMcpAuth verifier; the SDK maps it to user.id automatically (see Sessions & identity). To group calls into sessions, send a correlation header or supply resolveSession.

import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";

const handler = createMcpHandler((server) => {
  instrumentMcpServer(server, { zod: z, flush: "await" });

  server.tool("roll", "Roll an N-sided die", { sides: z.number() }, async ({ sides }) => ({
    content: [{ type: "text", text: String(1 + Math.floor(Math.random() * sides)) }],
  }));
});

// Put the END-USER id on authInfo.extra.userId → it becomes user.id (not clientId,
// which is the OAuth app, shared across users and used only as a coarse session).
const authed = withMcpAuth(handler, async (_req, token) => {
  const user = await verify(token);
  return { token, clientId: user.appId, scopes: [], extra: { userId: user.id } };
});

export { authed as GET, authed as POST, authed as DELETE };

Low-level Server and custom registries

A hand-rolled server that uses setRequestHandler(ListTools…, CallTool…) is supported by the same call:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";

const server = new Server({ name: "s", version: "1.0.0" }, { capabilities: { tools: {} } });
// ... your ListTools / CallTool handlers ...
instrumentMcpServer(server, { zod: z });

If you maintain a fully hand-rolled tools map with no McpServer / MCPServer object at all, instrument the map directly. (If your map lives inside an McpServer or a mastra MCPServer, use instrumentMcpServer on the server instead — not this.)

import { instrumentTools } from "@runotis/sdk/mcp";

const tools = instrumentTools(myToolsMap, { zod: z }); // augments each tool + adds otis_feedback

Sessions & identity

For the general session model — what counts as a session, the (sessionId, chatId) grouping, and auth-provider pass-through — see Sessions. This section covers only what's specific to MCP.

user.id vs session.id

withMcpAuth verifies the end-user and exposes their id on authInfo.extra.userId. The SDK maps that to user.id (hashed) on every tool span — the source for user-level analytics (segmentation, retention, cohorts). It is not used as the session: keying the session on a user id would make session count ≈ user count. (clientId is the OAuth app, shared across users, so it never populates user.id.)

How the session id is resolved

Otis groups a server's calls into sessions. You do not have to wire anything — the SDK resolves a stable session id automatically, in this order:

  1. resolveSession(extra), if you provide it.
  2. a client correlation header (X-Otis-Session-Id).
  3. the verified OAuth client id (authInfo.clientId).
  4. the transport session id (Mcp-Session-Id, on stateful HTTP).
  5. a stable per-server-instance id, so a session is never missing.

The X-Otis-Session-Id header is the same cross-domain header documented in Sessions → Cross-domain APIs — a client already sending it for a browser surface works for MCP unchanged.

MCP has no browser working-session, so the clientId and per-instance fallbacks land in the Acceptable (coarser) tier of Choosing what to use as your sessionId: stable, but closer to per-app / per-instance than a real working session. For a true per-user or per-conversation session, supply one explicitly via a correlation header or resolveSession.

For a local / stdio server (one process per client) the automatic id is exactly right with no configuration. Each instrumented server instance gets its own session namespace, so running more than one MCP server in a codebase never makes them collide.

One server connection can carry many conversations. A long-lived stdio process, a warm serverless instance, or a single OAuth client can span several unrelated chats — all of which the rules above resolve to one session id, blending them into one narrative. When the calling agent passes a per-conversation chat_id, the SDK folds it into the session id so each conversation becomes its own session. Nothing extra to wire on your side — the agent supplies chat_id; the resolution chain above still picks the base.

resolveSession is the lever on tool spans

resolveSession (rank 1) is how you inject a custom session. Note that an inherited withContext({ sessionId }) does not drive mcp.tool.* span sessions the way it does on ordinary spans — it applies only as the lowest-priority fallback (below the resolution chain), so any transport / header / client session wins over it. To force a session on MCP tool spans, use resolveSession — it receives the per-call MCP extra and may return undefined to fall through to the chain above:

instrumentMcpServer(server, {
  zod: z,
  // e.g. a per-conversation id your client controls; return undefined to fall through.
  resolveSession: (extra) => extra?.requestInfo?.headers?.["x-conversation-id"] as string | undefined,
});

(withContext({ userId }) and chatId do still apply to tool spans — only sessionId is special-cased to the resolution chain above.)

On stateless serverless the per-instance fallback is stable only within a warm instance — and if your handler rebuilds the server per request (the common createMcpHandler pattern), it is effectively per-call. The transport's Mcp-Session-Id is also not stable across cold invocations. So to group a user's calls at all, send a client correlation header (X-Otis-Session-Id) or supply resolveSession. (withMcpAuth restores user.id, but not the session.)

Serverless flush

On a runtime that freezes after responding, set flush: "await" so each call's telemetry is sent before the function suspends:

instrumentMcpServer(server, { zod: z, flush: "await" });

With flush: "await" the SDK awaits the export inside each tool call, so you do not need waitUntil / ctx.waitUntil around an MCP route — that pattern is only for streaming AI-SDK responses. The trade-off is that the export round-trip is added to each tool response's latency. This single setting is the whole story on AWS Lambda and Cloudflare Workers too (neither needs waitUntil for MCP). On a long-running server the default ("auto") is correct — telemetry is sent in the background.

On Cloudflare Workers, enable nodejs_compat in wrangler.toml. @runotis/sdk/mcp relies on AsyncLocalStorage (node:async_hooks) for context propagation, which nodejs_compat provides. See the Serverless guide.

Options

OptionDefaultPurpose
otisthe initOtis() instanceThe Otis instance to use.
zodYour z, used to extend zod-based input schemas (v3 or v4).
intenttrueInject the _otis intent block.
feedbacktrueInject the otis_feedback tool + result tickets.
feedbackMinTools2Suppress the feedback tool below this many tools (unless feedback: true).
resolveSessionDerive a stable session id from the call's extra.
surfacethe initOtis() defaultNamed origin for this server's spans (otis.surfaceSurface column), to tell apart multiple MCP servers sharing one service — e.g. "docs-mcp" vs "admin-mcp". Keep it stable and low-cardinality. See Tracing → Surface.
exclude[]Tool names to leave completely untouched.
captureInputtrueRecord the tool's input arguments (otis.input). Set false for sensitive inputs.
captureOutputtrueRecord the tool's return value (otis.output). Set false for sensitive outputs.
redacttrueRun captured input/output and intent text through client-side PII redaction.
flush"auto""await" on serverless; "auto" for long-running; "off" to manage flushing yourself.

Privacy

A tool's input arguments and return value are captured (as otis.input / otis.output) so they're visible in the dashboard. Because that content can contain PII, the SDK applies your project's client-side PII redaction to it by default — the same regex layer that scrubs emails, phone numbers, cards, SSNs, JWTs, and API keys from AI prompts. The agent's task_intent / session_intent are scrubbed too. The redaction honors your initOtis({ piiRedaction }) config and is a no-op if you've disabled redaction.

Controls:

instrumentMcpServer(server, {
  zod: z,
  captureInput: false,   // don't record arguments at all (e.g. a tool that takes credentials)
  captureOutput: false,  // don't record the return value
  redact: true,          // default — scrub captured I/O for PII; set false to record raw
});

For all-or-nothing per tool, exclude: ["my_tool"] leaves a tool completely uninstrumented.

Redaction of tool I/O is the client-side regex layer (structured PII). The server-side ML pass that catches unstructured PII like names and addresses runs on AI prompt/response attributes, not yet on tool I/O — so for tools that return free-form personal data, prefer captureOutput: false. Identifier hashing of userId / sessionId applies as everywhere else.

Next steps

On this page