# Telltale for MCP servers

Agents don't only work in your pages. Many reach your product through your own MCP server. Telltale for MCP servers records every tool call those agents make, per account, beside the agents in your pages and the agents using your API: one record of AI agent activity across every account and every channel.

It's one wrapper around the server you already have. Your tools, schemas and results stay as they are. Telltale never receives a call's content.

## What you get

- **Per account and per tool:** calls, failures (including the failures an MCP server returns as a normal result with `isError`), refusals and how long each call took.
- **Which agents:** the client name and version the MCP client declared about itself, such as Claude Code or Cursor, labelled **Declared**. That is what the software says it is, not a verified identity and never a person.
- **One record per account:** MCP calls are approved-route calls with channel `mcp`, so they land in the [agent-usage ledger](/docs/usage-ledger), the account drawer, Home, your agent report and Charge › Usage, split by channel (browser, MCP server, API).
- **Issues:** failed tool calls, calls to tools your server doesn't have, and calls your rules refused are counted in Issues and in agent goals.
- **Rules for protected tools:** a tool you name asks for your rule (Control › Rules) before it runs: allow, hold for review (with your own step-up) or deny.

## Install

Works with `@modelcontextprotocol/sdk` 1.x (`McpServer` or the low-level `Server`; tested with 1.17 and the latest 1.x) and `@modelcontextprotocol/server` 2.x, on stdio, Streamable HTTP and stateless HTTP, on Node 18+ or any runtime with `fetch` and Web Crypto. Tools you register after wrapping are recorded too. The adapter has no dependencies.

1. Download the adapter, check its SHA-256 against the [adapters table](/docs/server-adapters), and put it beside your server code. It is an ES module: in a project whose package.json says `"type": "commonjs"` (what `npm init` writes), save the two files as `telltale-mcp.mjs` and `telltale-mcp.d.mts` and import `./telltale-mcp.mjs`:

   ```sh
   curl -sSo telltale-mcp.js https://app.trytelltale.dev/adapters/v1/mcp.js
   curl -sSo telltale-mcp.d.ts https://app.trytelltale.dev/adapters/v1/mcp.d.ts
   ```

2. Wrap your server where you create it, with the Site's **server key** (Settings › Channels › Server keys). The key stays on your server:

   ```javascript
   import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
   import { z } from 'zod';
   import { withTelltale } from './telltale-mcp.js';

   const server = new McpServer({ name: 'my-app', version: '1.0.0' });
   const telltale = withTelltale(server, {
     endpoint: 'https://app.trytelltale.dev',
     serverKey: process.env.TELLTALE_SERVER_KEY,
     identify: ({ authInfo }) => ({
       accountId: authInfo?.extra?.accountId,   // your own opaque IDs
       userId: authInfo?.extra?.userId,
     }),
   });

   server.registerTool('export_report', { inputSchema: { month: z.string() } }, async ({ month }) => { /* … */ });

   process.on('SIGTERM', async () => { await telltale.close(); process.exit(0); });
   ```

3. Make a tool call from any MCP client. **Settings › Channels › MCP server › Is it working?** turns to "Working" with the time of the last batch, usually within five seconds.

### Stateless servers and serverless functions

A stateless server often builds a new server for every request (`createMcpHandler` in 2.x, or a 1.x transport without `sessionIdGenerator`). Create the Telltale client **once** and wrap each server inside that factory:

```javascript
import { createTelltale } from './telltale-mcp.js';

const telltale = createTelltale({ endpoint: 'https://app.trytelltale.dev', serverKey: process.env.TELLTALE_SERVER_KEY, identify });

export async function handle(request) {
  const server = telltale.wrap(buildServer());
  const response = await serve(server, request);
  await telltale.flush();          // before a serverless function is frozen
  return response;
}
```

On Cloudflare Workers, hand the flush to the platform: `ctx.waitUntil(telltale.flush())`. A process that ends on its own (a stdio server whose client went away) sends what is still queued first, once, within two seconds. Signals and `process.exit()` don't wait for that, so call `close()` in your shutdown handler; calls still queued when a process is killed without `close()` or `flush()` are lost (the adapter keeps nothing on disk).

## Confidence

Every MCP call and rule decision is shown as AI agent with a confidence band, never a probability. The band comes from the name the MCP client declared about itself when it connected (`clientInfo.name` in the MCP `initialize` request), matched without regard to case, spaces, hyphens or underscores:

| Client | Examples | Confidence |
| --- | --- | --- |
| A valid [agent token](/docs/approved-route#3-agent-tokens-for-your-api) your backend issued for this tool (any client name) | Verified by token | High |
| A known AI app | Claude Desktop, Claude Code, Cursor, ChatGPT, Windsurf, VS Code (Copilot), Codex, Gemini, Goose, Zed, Cline, Roo Code, Continue | High |
| A debugging or test tool | MCP Inspector, MCPJam Inspector, Postman, MCP CLI | Medium |
| Any other name, or no name | "Other MCP client", "Not declared" | Medium |

- **High** follows the tool's AI agent rule, Deny included, and a call with an account is chargeable.
- **Medium** still shows as AI agent. A rule that would deny it holds it for review instead (unless your site blocks medium confidence, in Settings), and it is never chargeable. A developer clicking "call tool" in MCP Inspector is the usual case.
- The name is what the client says it is (**Declared**), not a verified identity, and never a person.
- A valid agent token is verified identity, so the call is High whatever client name it carries. A token that is unknown, revoked, expired or not issued for this tool changes nothing: the client name decides.

## Identity

`identify(ctx)` returns `{ accountId, userId }` from your own authentication: `ctx.authInfo` (what your transport's MCP auth middleware verified: `extra.authInfo` in 1.x, `ctx.http.authInfo` in 2.x), `ctx.extra` (the MCP SDK's request context; without MCP auth, read your own header there: `extra.requestInfo.headers` in 1.x, `ctx.http.req.headers` in 2.x) and `ctx.sessionId`. A stdio server has no headers: use the key your server already reads, for example from its environment. Send opaque IDs only, never an email address or a name.

- It runs for every call, with that call's own request context, so one shared server never gives one caller's account to another's call.
- It runs beside the tool call, never before it, so it never adds latency (except for protected tools, which need the account for the rule).
- A call with an account your server named, from a known AI app (high confidence, see [Confidence](#confidence)), is chargeable in the ledger and counts toward the account's record. A call without an account, or from a medium-confidence client, is counted, never chargeable.
- **Identified users are counted once across channels.** A person your server identified in the browser and who also works through your MCP server is one identified user that month on Telltale's plans.
- An [approved-route agent token](/docs/approved-route) names the account itself: see Rules below.

### Grouping calls into agent sessions

Calls are grouped into agent sessions, also on stateless servers:

1. `session(ctx)` if you give one: your own conversation or task ID.
2. Otherwise the transport's session (stateful Streamable HTTP and SSE).
3. Otherwise the account, user and client together, split after 30 idle minutes. A call with no account, no user and no session is its own session: two anonymous callers are never merged.

Each key is hashed (SHA-256) before it leaves your server; the raw session ID is never sent. Some clients reset their session ID in the middle of a conversation, so your own key is the most reliable choice.

## Privacy: what is and isn't sent

Sent to Telltale for each call, and nothing else:

| Field | What it is |
| --- | --- |
| `eventId` | a random ID for the call, so a retried batch is never counted twice |
| `tool` | the tool name |
| `startedAt`, `endedAt` | when the call started and ended (milliseconds) |
| `outcome` | `ok`, `error`, `refused` or `cancelled` |
| `errorClass` | for an error: `tool_error` (your tool failed: it threw or returned an `isError` result), `invalid_params` (the arguments were refused before your tool ran), `unknown_tool` (no such tool, or a disabled one), `internal_error`, `timeout` or `other`. A category, never the message |
| `refusedBy` | for a refusal: `deny`, `hold` or `unreachable` |
| `client` | `{name, version}` the MCP client declared about itself |
| `accountId`, `userId` | what your `identify` returned |
| `session` | `{key, source}`: a SHA-256 hash and where it came from (`customer`, `transport` or `derived`) |
| `reason` | only for tools where you turned on the why field (below) |
| `decisionId`, `tokenId` | the rule decision or the agent token behind a protected call |
| `responseBytes`, `responseTokens` | adapter 1.1.0 and later: the size of the tool's result, counted by the adapter on your server: its UTF-8 bytes serialised as JSON (what the transport sends) and an estimated token count (bytes / 4, rounded up). Used for **Oversized responses** in Issues. Two numbers; the result itself is never sent |
| `captured` | only with [Deep insights](/docs/deep-insights) on (owner-enabled, off by default): the fields you listed for that tool |

Each batch also names the adapter's version and your server's name and version. Telltale refuses any other field.

**Never sent** (unless you turn on [Deep insights](/docs/deep-insights) and list fields, and even then never headers or credentials): arguments, results, error messages and stack traces, HTTP headers, tokens, cookies, `authInfo`, the raw MCP session ID and the caller's IP address. The adapter sends no diagnostics about itself anywhere, and it never writes to stdout (a stdio server uses stdout for the protocol); `debug: true` writes to stderr.

### Data stays with you

To keep the full calls for your own debugging, pass them to your own logs or OpenTelemetry. The hook gets everything (arguments, result, error, the MCP request context); Telltale gets none of it:

```javascript
withTelltale(server, {
  endpoint: 'https://app.trytelltale.dev', serverKey: process.env.TELLTALE_SERVER_KEY, identify,
  dataStaysWithYou: (call) => logger.info({ tool: call.tool, outcome: call.outcome, args: call.arguments, ms: call.durationMs }),
});
```

## The why field (opt in, per tool)

Off by default, and it takes two steps: name the tools in `why`, and turn on [Understand what agents are trying to do](/docs/understand) for the workspace (Connect your channels or Home). While both are on, each tool you name gains one optional string parameter in its schema, `reason`, which asks the agent in one sentence why it is calling the tool:

```javascript
withTelltale(server, { /* … */, why: ['export_report', 'delete_project'] });
```

- The adapter follows the workspace switch (since 1.4.0): it reads `GET /api/v1/mcp/why` with the server key when it starts, and again at most every five minutes. While the switch is off, `tools/list` is left exactly as your server answers it, no `reason` parameter is added and no reason is sent. Turned on, the parameter appears in the next `tools/list` after the adapter reads the switch again (clients that cached the list see it when they list again). If Telltale can't be reached, the adapter keeps the last answer. Older adapters always add the parameter, and Telltale discards their reasons while the switch is off.
- Only the tools you name change, and only by that optional parameter. Agents that leave it out work as before.
- The adapter removes `reason` before your handler runs, so your code never sees it. If a tool already has its own `reason` parameter, the adapter leaves that tool alone: its value reaches your handler and is never sent. On the low-level `Server`, the adapter only removes `reason` for tools whose `tools/list` it answered in the same process; otherwise the argument reaches your handler as the agent sent it.
- The text is cut to 280 characters, kept 30 days and shown in the account drawer as "Agent said:". It is the agent's own words, not a verified fact. Tell your users in your terms that you collect it: an agent may put personal data in it.

## Deep insights (opt in, owner-enabled)

Off by default. When the site owner turns it on and you list fields per tool in `deepInsights`, Telltale keeps those argument and `structuredContent` fields for 7 to 30 days and shows them as "Captured fields" in the session drawer. Unlisted fields never leave your server, your `redact` hook runs before anything is sent, and headers, tokens, passwords and other credentials are never stored, whatever you list. See [Deep insights](/docs/deep-insights). The why field above and Deep insights are separate switches; both are off until you turn them on.

## Agent feedback (opt in, since 1.3.0)

With `feedback: true` and agent feedback turned on for the workspace, the SDK adds one tool, `agent_feedback`, where an agent can say what it needed and could not find. The SDK answers it itself (it never reaches your handlers and is never counted as a call), redacts the note and sends at most 500 characters. Notes show in Issues, on the session and in the bell. See [Agent feedback](/docs/agent-feedback).

## Rules for protected tools

Name the tools that should ask for your rule (Control › Rules) before they run:

```javascript
withTelltale(server, {
  /* … */,
  protect: {
    tools: ['export_all'],            // read tools: fail open when Telltale can't be reached
    writeTools: ['delete_project'],   // write tools: fail closed when Telltale can't be reached
    // whenUnreachable: 'open' | 'closed' | { read, write } to change that
  },
  onHold: async ({ tool, decisionId, accountId, userId }) => {
    const ok = await askThePersonInYourApp(userId, tool);   // your own step-up
    return ok ? { result: 'passed', method: 'own_approval' } : 'failed';
  },
});
```

- Save the rule in Control › Rules under the tool's name (tools seen through your MCP server are listed, marked "MCP server"). Every MCP caller is an AI agent's client, so the **AI agent** column decides. A protected tool with no saved rule follows the defaults, like a protected action in your pages: **Hold for review**. Save Allow for the tools agents may run freely. (A tool name with dots or slashes can't carry a rule and is allowed.)
- **Deny:** the tool doesn't run. The agent gets an `isError` result saying the action isn't allowed for AI agents. A medium-confidence call (see [Confidence](#confidence)) is held for review instead, unless your site blocks medium confidence.
- **Hold for review:** the tool doesn't run yet. With `onHold`, your own step-up runs and, if it passes, the call goes ahead. Otherwise the agent is told the action is held for review; once a teammate approves it in Control › Approvals › Waiting or your step-up passes, the agent's next try runs (once, within 15 minutes).
- **Agent tokens:** if your server accepts [approved-route agent tokens](/docs/approved-route), return the token from `agentToken: ctx => …`. Issue tokens for MCP tools with `mcpTools: ['delete_project']`. A valid token names the account, makes the call High confidence and turns a hold into allow; a Deny rule still denies.
- The rule check waits at most 1.5 seconds (`protect.decideTimeoutMs`). It sends the client's declared name and version with the tool and account. An Allow answer is reused for 30 seconds for the same tool, account and client (`protect.cacheMs`); holds, denials, approvals and tokens are checked every time.
- A rule decision is evidence that Telltale's rule allowed the call. It is not authorization: your server still checks what the account and user may do.

## Batching and outages

Calls are queued in memory and sent in batches of up to 100 every five seconds. Network errors, 408, 429 and 5xx answers are retried with growing waits (up to a minute, honouring `Retry-After`). Any other refusal drops the batch, logged once with `debug: true`. The queue holds 10,000 calls; past that the oldest are dropped and counted ("dropped" in Is it working?). **A Telltale outage never slows or fails a tool call.** Only protected tools wait, and only up to their timeout.

## Performance, tags, events and debug mode

**Issues** lists, beside the errors, **Slow tool calls** (tools whose calls ran longer than your limit, 30,000 ms by default) and **Oversized responses** (tools whose results were larger than your token limit, 20,000 tokens by default: a result that big crowds out the agent's earlier conversation). Set both limits for the Site, or per tool, in Issues › Limits (owners and admins). Neither is an error: error counts and error rates never change. Upgrade to adapter 1.1.0 to send result sizes; slow calls work with any version.

**Tools › All tools** shows each tool's agent calls, error rate and p50/p95 time, by MCP client and by tag. Add `tags: ctx => ({ plan: 'pro' })` for categories, call `telltale.event('report.exported', { accountId })` for moments in your product, and set `debug: true` (or `TELLTALE_DEBUG=1`) to see what is sent, on stderr only. The bell tells you when a tool starts failing, when a call is slow, when a new MCP client shows up, when an issue you resolved comes back, and when an account makes its first agent call. Details and the tag rules: [Custom events, tags and debug mode](/docs/custom-events).

## Pricing and limits

MCP calls are included in every plan. Telltale's plans count identified users a month, once across channels, never calls or sessions. Call volume is under fair use: about 100,000 MCP calls a month on Starter and 1,000,000 on Growth. Past that we talk to you before anything changes, and collection never stops. The server accepts up to 1,200 batches and 6,000 rule checks a minute per Site; a 429 just makes the adapter wait.

## Troubleshooting

- **"Waiting for the first call" stays on.** Check the server key (a publishable `ao_pk_` key is refused) and that your server can reach `https://app.trytelltale.dev`. Turn on `debug: true` (or set `TELLTALE_DEBUG=1`) and look at stderr: each queued call and each batch's answer is one line, shapes only.
- **Calls but no accounts.** `identify` returned nothing: log what `ctx.authInfo` holds on your server. Calls without an account are counted, never chargeable.
- **"Not declared" clients.** A client introduces itself once, in `initialize`; on a stateless server that request may have gone to another instance. Clients on the 2026-07-28 protocol name themselves on every request, so this only happens with older clients. Return it yourself with `clientOf: ctx => ({ name, version })` if your transport knows it.
- **A tool that asks the client for input.** On the 2026-07-28 protocol a tool can answer `input_required` and be called again with the answers. That is one call, recorded when the round that completes it returns; its duration covers that round.
- **A call counted twice.** A client that re-sends a request after a dropped connection runs the tool twice; both runs are recorded, because both happened.
- **Durations look short next to client timeouts.** The duration is measured on your server. A client that gave up after its own timeout still shows the server's full time.
- **Rejected calls.** Is it working? says how many calls in the last batch were refused and which field. Update the adapter if it is older than the server.

See [Approved route for agents](/docs/approved-route) for agent tokens, and [Agent-usage ledger](/docs/usage-ledger) for how calls are counted.
