Skip to content
Browse the documentation
MCP recorder

MCP recorder (Python)

A small stdio proxy that sits between an MCP client (Claude Desktop, Claude Code, Cursor, an agent framework) and any downstream MCP server (a Stripe or Zendesk MCP server, your own). It forwards every JSON-RPC…

Last changed Markdown versionllms.txt

A small stdio proxy that sits between an MCP client (Claude Desktop, Claude Code, Cursor, an agent framework) and any downstream MCP server (a Stripe or Zendesk MCP server, your own). It forwards every JSON-RPC message unchanged in both directions, and when the client calls a mapped tool it records the intent as a CommitLayer action before forwarding the call. Standard library only; Python 3.10+.

The agent may declare its intent. It may read the verdict. It must never control the independent facts, the governing contract, or the final evidence.

The recorder only does the first of those three. It never blocks, reorders or alters the agent's traffic, and the downstream tool's result is never sent to CommitLayer: a tool answering "refund issued" is a claim, not evidence. Verification reads the systems of record.

pip install ./recorder/python           # from this repository; `pip install pyyaml` for .yaml mappings
export COMMITLAYER_API_KEY=spk_...      # a workspace API key; without it the proxy is a passthrough
commitlayer-mcp-recorder --map mapping.json -- npx -y @stripe/mcp --tools=all

Wiring it into a client

Wherever the client is told how to start the server, put the recorder in front and move the real command after --.

Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "stripe": {
      "command": "commitlayer-mcp-recorder",
      "args": ["--map", "/etc/commitlayer/stripe.json", "--agent-id", "support-bot", "--", "npx", "-y", "@stripe/mcp", "--tools=all"],
      "env": {"COMMITLAYER_API_KEY": "spk_..."}
    }
  }
}

Claude Code:

claude mcp add stripe -e COMMITLAYER_API_KEY=spk_... -- commitlayer-mcp-recorder --map stripe.json -- npx -y @stripe/mcp --tools=all

Cursor and other clients use the same command / args / env shape. The downstream server does not know the recorder is there; it sees the same bytes it would have seen.

CLI

commitlayer-mcp-recorder --map mapping.json [--agent-id ID] [--workflow-id ID] [--api-url URL]
    [--timeout 5] [--no-fail-open] [--warn-unmapped] -- <downstream command and args>
Flag Meaning
--map Mapping file, JSON or YAML (YAML needs PyYAML). Default $COMMITLAYER_RECORDER_MAP.
--agent-id, --workflow-id Override agent_id / workflow_id from the mapping file.
--api-url Default $COMMITLAYER_API_URL, then https://api.commitlayer.ai.
--timeout Seconds to wait for the API per call (default 5).
--no-fail-open When recording fails, refuse the call instead of forwarding it (see below).
--warn-unmapped Log each tool that is called but not mapped, once per tool name.

Environment: COMMITLAYER_API_KEY (required to record; without it the recorder warns once at startup and runs as a pure passthrough), COMMITLAYER_API_URL, COMMITLAYER_RECORDER_MAP. The key is never logged. All diagnostics go to stderr, one line each, prefixed commitlayer-recorder:. The downstream server's own stderr is relayed as is.

The proxy exits when the client closes stdin or the downstream server exits, with the server's exit code.

Mapping file

{
  "agent_id": "support-bot",
  "workflow_id": "refunds",
  "tools": {
    "refund_payment": {
      "contract_id": "refund_workflow",
      "action_type": "refund_order",
      "subject": "arguments.order_id",
      "subject_kind": "order",
      "params": {"amount": "arguments.amount", "currency": "arguments.currency"},
      "action_id": "refund:{arguments.order_id}",
      "compensates": "arguments.original_action_id"
    }
  }
}
  • Keys under tools are MCP tool names. An exact match wins; a key with a server prefix (stripe.refund_payment) also matches the bare name (refund_payment).
  • contract_id and subject are required per tool. Unknown keys are allowed and ignored.
  • subject, each params value and compensates are dotted paths into the tools/call params object: arguments.order_id, name, _meta.progressToken, arguments.items.0.
  • action_id is a template with {path} placeholders. Without it the id is mcp:{tool}:{request id}, so a retried request updates one record instead of creating two.
  • contract_version (integer), action_type and subject_kind are passed through.
  • A params path that is absent from the call is skipped; an absent compensates path is omitted. An absent subject (or action_id placeholder) means the call is forwarded but not recorded, with a warning.

The mapping is validated on load; an invalid file stops the proxy with exit code 2 and a message naming the tool and key.

Example: the Coinbase MCP server

examples/coinbase.json maps orders_create (the order tool of the Coinbase MCP server behind Coinbase for Agents) to the coinbase_agent_trade library contract: the subject is the product id the agent passes, amount is its quote size, side and client_order_id ride along, and the action id is derived from the client order id so a retried call updates one record. The paths follow the create-order request; run the recorder once with --warn-unmapped against the server's own tools/list and adjust them if the argument names differ. The Coinbase server is remote (HTTP), and the recorder is a stdio proxy: put it in front of whatever stdio bridge the client uses to reach the remote server, so the bridge is the "downstream command". Verification then reads the order and its fills from Coinbase through the Coinbase connector; the tool's own answer is never evidence.

What is recorded

For each client → server tools/call whose params.name is mapped, the recorder posts one action to POST {api_url}/v1/actions (x-api-key, content-type: application/json) and then forwards the original line unchanged:

{
  "action_id": "refund:order_10231",
  "contract_id": "refund_workflow",
  "subject_id": "order_10231",
  "subject_kind": "order",
  "action_type": "refund_order",
  "agent_id": "support-bot",
  "workflow_id": "refunds",
  "requested_at": "2026-10-15T12:00:00Z",
  "params": {"amount": 180.0, "currency": "USD", "mcp_tool": "refund_payment", "mcp_request_id": "7"},
  "channel": "mcp"
}

params always carries mcp_tool and mcp_request_id. requested_at is taken when the call is seen, so it precedes every effect. Unmapped tools, notifications, initialize, tools/list and everything else pass straight through.

When the downstream answer for a recorded request comes back, the recorder logs one line:

commitlayer-recorder: recorded refund:order_10231 → tool result isError=false

That is all it does with the result. The tool's own "success" is never evidence. It is not sent to the API and it does not change the verdict; CommitLayer settles the action from the facts the systems of record report, under the governing contract. Read the verdict with the SDK or GET /v1/actions/{action_id}.

Fail-open, and the alternative

By default recording is fail-open: if the API answers 4xx/5xx, times out or cannot be reached, a warning is written to stderr and the call is forwarded anyway. A verifier that is down must not stop the business action; the gap shows up as an action that was never recorded rather than one that never happened.

With --no-fail-open a call whose recording fails is not forwarded. The client receives a JSON-RPC error for that request id (code -32000, message CommitLayer recording failed: …) and the downstream server never sees it. Other traffic is unaffected.

Library use

from commitlayer_mcp_recorder import Recorder, build_action, load_mapping

mapping = load_mapping("mapping.json")
recorder = Recorder(mapping, api_key="spk_...", agent_id="support-bot")
decision = recorder.before_forward(message)  # a parsed client message; .forward, .action_id, .error
recorder.after_response(message)  # a parsed server message; logs the isError flag

build_action(tool_name, call_params, tool_mapping, request_id=...) returns the body shown above.

Limits

  • Stdio transport only, newline-delimited JSON-RPC. Lines that are not JSON are forwarded untouched.
  • JSON-RPC batch arrays are forwarded untouched and not inspected.
  • A tools/call without a JSON-RPC id is forwarded without recording (there is nothing to match the answer to).

Tests

python -m pytest recorder/python/tests -q