Recorder — every MCP tool call, on the record

Scout says what a machine has. The Recorder says what an agent does with it. It is an MCP proxy: one line in the host's config puts it between Claude Code, Claude Desktop, Cursor, Windsurf, Codex or any other MCP host and the server they talk to. Every JSON-RPC message passes through untouched. Every tools/call becomes an activity event on the organisation's signed ledger: which agent, which server, which counterparty, which data classes, what outcome, how long. Arguments and results never leave the pipe.

openwake-recorder --agent acme/claude-code -- npx -y @notionhq/notion-mcp-server
openwake-recorder --agent acme/claude-code --url https://mcp.linear.app/mcp --header "Authorization: Bearer …"
openwake-recorder --agent acme/claude-code --enforce --classes customer_content --tool-classes "search*=none" -- uvx mcp-server-slack
openwake-recorder wrap ~/.claude.json --agent acme/claude-code --key ow_live_…      # every server in the file, in one go

In this repository: pnpm recorder -- …. Published: npx -y @openwake/recorder ….

One line in the host's config

Before:

"notion": { "command": "npx", "args": ["-y", "@notionhq/notion-mcp-server"], "env": { "NOTION_TOKEN": "…" } }

After:

"notion": {
  "command": "npx",
  "args": ["-y", "@openwake/recorder", "--agent", "acme/claude-code", "--label", "notion", "--", "npx", "-y", "@notionhq/notion-mcp-server"],
  "env": { "NOTION_TOKEN": "…", "OPENWAKE_API_KEY": "ow_live_…" }
}

wrap <file> makes that edit for every server in a Claude Code, Claude Desktop, Cursor or Windsurf config (plain JSON with mcpServers, plus Claude Code's per-project servers). It keeps a timestamped backup, skips entries already wrapped, and converts url entries to --url with their headers. Hosts whose config is JSONC or TOML (VS Code, Zed, Codex) are wrapped by hand with the same arguments. --recorder "openwake-recorder" (or any command) tells the host how to start the recorder when it is installed rather than fetched with npx.

If the org key is missing, or the server cannot be registered (a plan cap, a wrong parent), calls are forwarded and counted as not recorded; in enforce mode the checks still run, so a deny still blocks.

The wrapped server is spawned with the host's environment minus every OPENWAKE_* variable: it never sees the org key. Its stderr passes through.

What is recorded

FieldValue
agent_id<agent>/mcp/<label>, registered as an mcp_server submodule under --agent (the agent itself is registered on first use if the org has not)
kindtool_call
counterparty--counterparty, else derived from the package or URL (@notionhq/notion-mcp-servernotion, https://mcp.linear.app/mcplinear.app); omitted for local servers
data_classesthe first matching --tool-classes pattern, else --classes, else none
toolthe tool's name as the server lists it
actionfrom the server's own tool annotations: read (readOnlyHint), destructive (destructiveHint), write (annotated, neither), unknown (the server declares none). Read off the tools/list answer; nothing else is inspected
outcomecompleted; failed on an isError result, a JSON-RPC error, a cancel, or a server that went away; blocked in enforce mode
check_idin enforce mode, the signed check the decision rests on
detailtool=<name> server=<label> ms=<duration> and, when relevant, why it failed or was blocked; sealed at rest
acting_for, task_id--acting-for (or OPENWAKE_ACTING_FOR) names who the host acts for, --task (or OPENWAKE_TASK_ID) the task the calls belong to; both recorded on every event when given

Tool names and annotations are recorded; arguments, results, prompts and file contents are not read, not hashed, not counted. The dashboard's Live page shows each call as it lands, and counts destructive actions per session.

Enforce mode

--enforce (or OPENWAKE_RECORDER_ENFORCE=1) asks POST /v1/check for the counterparty and data classes before forwarding. A deny (the vendor states it trains on customer data and the call carries a sensitive class) is answered to the host as a tool error quoting the vendor's own words and the profile URL; the server is never called and the event is blocked with the check's id. allow and review forward. Verdicts are cached for five minutes per counterparty and class set. If Openwake is unreachable the call goes through and the recorder says so on stderr: enforcement is advisory by construction, never a single point of failure.

The policy an org sets on the agent (PATCH /v1/agents/:id with allowed data classes and counterparties) is scored on every event either way; out-of-policy answers are printed on stderr and flagged in the dashboard.

Failure posture

The pipe never breaks for the sake of the record. No API key: forward, say so once. The server cannot be registered (plan cap, wrong parent): forward, say so once. An event fails to send: warn, keep forwarding. The host closes: close the server, fail what was still pending, wait for the last events, exit.

Library use

@openwake/recorder exports the proxy for hosts that embed servers in-process: new Recorder(hostTransport, upstreamTransport, openwakeClient, config) with any two MCP transports and an @openwake/sdk client (or anything with logActivity and check). MemoryTransport.pair() joins two in-process ends, which is how the tests run: no network, no child processes.