mcptape
by EAnge1o
README.md
# mcptape
<img src="docs/logo.svg" alt="mcptape" width="420" align="center" />
[](https://github.com/EAnge1o/mcptape/actions/workflows/ci.yml)
[](https://pypi.org/project/mcptape/)
[](https://python.org/)
[]()
[](LICENSE)
**mcptape** is a **tape recorder for the Model Context Protocol**: wrap any MCP server, record a session once (REC ⏺), replay it offline forever (PLAY ▶), and diff exactly where the tool behavior drifted (⏩).
> Your agent talks to MCP servers — filesystem, browser, search, your own tools. Those servers change, their tools change, their results change, and your agent breaks in ways you cannot reproduce without paying for a dozen live runs. mcptape records the whole session — every `tools/call`, every result, every notification — as local JSONL. From then on, replay serves the recorded results offline for free and byte-identically, and one command diffs two recordings to the first drifted call.
Zero dependencies: pure Python standard library. Nothing leaves your machine — a trace is a local file.

*(The screenshot is rendered from real command output by `docs/make_demo_svg.py` — not staged.)*
中文文档:[README.zh-CN.md](README.zh-CN.md)
## How it works
```mermaid
flowchart LR
A["MCP client<br/>(your agent)"] -->|"stdio"| B["mcptape rec<br/>transparent proxy ⏺"]
B -->|"stdio"| C["real MCP server"]
B --> D[".mcptape/runs/<br/>trace.jsonl + meta.json"]
A -->|"stdio"| E["mcptape play<br/>served from tape ▶"]
D -.->|"byte-for-byte"| E
F["second recording"] --> G["mcptape diff<br/>first drifted call"]
E -.-> G
G -->|"exit 0 / 1 / 2"| H["CI ✅ ❌"]
```
In **rec** mode mcptape is a transparent proxy: it spawns the real server, forwards every JSON-RPC line both ways unchanged, and files each message to the tape. Your agent cannot tell it is being recorded.
In **play** mode mcptape *is* the server: it answers `initialize`, `tools/list` and `tools/call` from the tape — no child process, no network, identical results.
## 30 seconds
```bash
pip install mcptape
# 1. record a session: put mcptape between your client and any MCP server
python examples/demo_client.py -- mcptape rec --name demo-stable -- python examples/demo_server.py
# 2. replay it offline -- same results, no server process, $0.00
python examples/demo_client.py -- mcptape play demo-stable
# 3. the server changes; record again and diff
MCPTAPE_DEMO_DRIFT=1 python examples/demo_client.py -- mcptape rec --name demo-drift -- python examples/demo_server.py
mcptape diff demo-stable demo-drift
```
Real output (paste the commands and you get this verbatim):
```
=== replay ===
server: demo-tools
tools: echo, add
echo: tapedeck, but for MCP
add(2, 3): 5
=== diff demo-stable demo-drift ===
run A: demo-stable (11 events)
run B: demo-drift (11 events)
event 10 (s2c response):
result changed
A: {"result": {"content": [{"type": "text", "text": "5"}]}}
B: {"result": {"content": [{"type": "text", "text": "6"}]}}
context (events …i):
c2s/request tools/call
s2c/notification notifications/message
```
`diff` exit codes are designed for CI: `0` identical, `1` output drift, `2` structural drift.
## In your agent config
`rec` is one config edit away from any stdio-based MCP client (Claude Desktop, Claude Code, Cursor, ...):
```json
{
"mcpServers": {
"my-tools": {
"command": "mcptape",
"args": ["rec", "--name", "daily-run", "--", "python", "-m", "my_tools"]
}
}
}
```
Flip `"rec"` to `"play", "daily-run"` and the agent runs against the tape — full regression pass with zero live calls. The client cannot tell the difference: same tools, same results, same notifications.
## Why matching is by content, not position
tapedeck aligns calls by position; MCP clients pipeline and run tool calls **in parallel**, so positional alignment would false-positive on every concurrent session. mcptape matches each incoming call against the earliest *unconsumed* recorded exchange with the same method and JSON-equal arguments. Identical repeated calls consume identical recorded entries in order; reordered or parallel calls match freely; a call whose arguments changed matches nothing — that is the drift, reported with the exact arguments.
`initialize` is matched leniently on parameters (client name/version/protocol bumps are not behavior drift) and its recorded capabilities answer is served verbatim.
## Why not LangSmith / LangFuse / OTel platforms
| | Cloud observability | **mcptape** |
|---|---|---|
| Position | watch runs (dashboard) | **reproduce runs (recorder)** |
| Replay | re-calls the tools (state changed, money spent) | **replays recorded results (free, deterministic)** |
| Data | uploaded to a third party | **local files only** |
| Dependencies | SDK + reporting config | zero, two commands |
Complementary, not competing: platforms show trends; mcptape does forensics and regression. The same role [tapedeck](https://github.com/EAnge1o/tapedeck) plays for model calls, one layer down.
## CLI
```text
mcptape rec --name RUN -- <server command> # proxy + record a live session
mcptape play RUN [--lenient] # serve a recording as an MCP server
mcptape diff A B # first drift + context; exit 0/1/2
mcptape ls # list recorded runs
mcptape report RUN # per-message table
```

## Trace format
One directory per run, two plain files:
```
.mcptape/runs/20261031-011921-demo-stable/
meta.json # server command, protocol version, counts
trace.jsonl # one JSON-RPC message per line, with request echoes
```
```json
{"v": 1, "i": 10, "dir": "s2c", "kind": "response", "id": 4,
"payload": {"result": {"content": [{"type": "text", "text": "5"}]}},
"for_method": "tools/call", "for_params": {"name": "add", "arguments": {"a": 2, "b": 3}},
"wall_ms": 0.9}
```
Every response carries an echo of the request it answers, so each line is self-contained — replay and diff never need to join across lines.
## Honest boundaries
- **stdio transport only (v0.1).** HTTP/SSE-based MCP servers are not wrapped yet; streamable-HTTP support is on the roadmap.
- **Sessions with server-initiated requests cannot be replayed.** If the server asked the client to sample or elicit, the tape would have to impersonate the client — `play` refuses such recordings up front (exit 2); `diff` still works on them.
- **Replay is deterministic only up to the tape.** Client-side logic that reads the clock, uses randomness, or branches on anything outside the session may take a different path than during recording — that is true of every record/replay tool and is exactly what `diff` between two real recordings is for.
- **Server-side state is frozen at record time.** Replaying a `tools/call` that wrote a file serves the recorded result without writing again; replay never touches the real world. If you need the side effects re-applied, that is a live run, not a replay.
- **No monkeypatching.** `rec` is a proxy process; the client config opts in explicitly.
## Roadmap
- [ ] streamable-HTTP transport wrapping
- [ ] `--subset` replay (serve only named tools from the tape, live for the rest)
- [ ] trace signing (tamper-evident hash chain)
- [ ] OTel GenAI span import/export
## The tape suite
The same "record once, replay forever" idea, at three layers:
- **[tapedeck](https://github.com/EAnge1o/tapedeck)** — model & tool calls *inside* your agent (Python, PyPI)
- **mcptape** — the MCP tool-protocol boundary (this repo)
- **[mocktape](https://github.com/EAnge1o/mocktape)** — HTTP traffic behind your frontend (TypeScript, npm)
Design trade-offs live in the source docstrings; every image in this README is rendered from real command output.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues