mcptape 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcptape-0.1.0/LICENSE +21 -0
- mcptape-0.1.0/PKG-INFO +187 -0
- mcptape-0.1.0/README.md +168 -0
- mcptape-0.1.0/mcptape/__init__.py +26 -0
- mcptape-0.1.0/mcptape/__main__.py +7 -0
- mcptape-0.1.0/mcptape/cli.py +167 -0
- mcptape-0.1.0/mcptape/diff.py +93 -0
- mcptape-0.1.0/mcptape/proxy.py +187 -0
- mcptape-0.1.0/mcptape/replay.py +169 -0
- mcptape-0.1.0/mcptape/trace.py +149 -0
- mcptape-0.1.0/mcptape.egg-info/PKG-INFO +187 -0
- mcptape-0.1.0/mcptape.egg-info/SOURCES.txt +16 -0
- mcptape-0.1.0/mcptape.egg-info/dependency_links.txt +1 -0
- mcptape-0.1.0/mcptape.egg-info/entry_points.txt +2 -0
- mcptape-0.1.0/mcptape.egg-info/top_level.txt +1 -0
- mcptape-0.1.0/pyproject.toml +30 -0
- mcptape-0.1.0/setup.cfg +4 -0
- mcptape-0.1.0/tests/test_mcptape.py +393 -0
mcptape-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 E'Angelo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mcptape-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcptape
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tape recorder for the Model Context Protocol: record any MCP server once, replay it offline forever, diff what drifted. Zero dependencies.
|
|
5
|
+
Author: E'Angelo
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/EAnge1o/mcptape
|
|
8
|
+
Project-URL: Issues, https://github.com/EAnge1o/mcptape/issues
|
|
9
|
+
Keywords: mcp,model-context-protocol,agent,replay,testing,record
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# mcptape
|
|
21
|
+
|
|
22
|
+
<img src="docs/logo.svg" alt="mcptape" width="420" align="center" />
|
|
23
|
+
|
|
24
|
+
[](https://github.com/EAnge1o/mcptape/actions/workflows/ci.yml)
|
|
25
|
+
[](https://pypi.org/project/mcptape/)
|
|
26
|
+
[](https://python.org/)
|
|
27
|
+
[]()
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
**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 (⏩).
|
|
31
|
+
|
|
32
|
+
> 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.
|
|
33
|
+
|
|
34
|
+
Zero dependencies: pure Python standard library. Nothing leaves your machine — a trace is a local file.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
*(The screenshot is rendered from real command output by `docs/make_demo_svg.py` — not staged.)*
|
|
39
|
+
|
|
40
|
+
中文文档:[README.zh-CN.md](README.zh-CN.md)
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
```mermaid
|
|
45
|
+
flowchart LR
|
|
46
|
+
A["MCP client<br/>(your agent)"] -->|"stdio"| B["mcptape rec<br/>transparent proxy ⏺"]
|
|
47
|
+
B -->|"stdio"| C["real MCP server"]
|
|
48
|
+
B --> D[".mcptape/runs/<br/>trace.jsonl + meta.json"]
|
|
49
|
+
A -->|"stdio"| E["mcptape play<br/>served from tape ▶"]
|
|
50
|
+
D -.->|"byte-for-byte"| E
|
|
51
|
+
F["second recording"] --> G["mcptape diff<br/>first drifted call"]
|
|
52
|
+
E -.-> G
|
|
53
|
+
G -->|"exit 0 / 1 / 2"| H["CI ✅ ❌"]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
## 30 seconds
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install mcptape
|
|
64
|
+
|
|
65
|
+
# 1. record a session: put mcptape between your client and any MCP server
|
|
66
|
+
python examples/demo_client.py -- mcptape rec --name demo-stable -- python examples/demo_server.py
|
|
67
|
+
|
|
68
|
+
# 2. replay it offline -- same results, no server process, $0.00
|
|
69
|
+
python examples/demo_client.py -- mcptape play demo-stable
|
|
70
|
+
|
|
71
|
+
# 3. the server changes; record again and diff
|
|
72
|
+
MCPTAPE_DEMO_DRIFT=1 python examples/demo_client.py -- mcptape rec --name demo-drift -- python examples/demo_server.py
|
|
73
|
+
mcptape diff demo-stable demo-drift
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Real output (paste the commands and you get this verbatim):
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
=== replay ===
|
|
80
|
+
server: demo-tools
|
|
81
|
+
tools: echo, add
|
|
82
|
+
echo: tapedeck, but for MCP
|
|
83
|
+
add(2, 3): 5
|
|
84
|
+
|
|
85
|
+
=== diff demo-stable demo-drift ===
|
|
86
|
+
run A: demo-stable (11 events)
|
|
87
|
+
run B: demo-drift (11 events)
|
|
88
|
+
event 10 (s2c response):
|
|
89
|
+
result changed
|
|
90
|
+
A: {"result": {"content": [{"type": "text", "text": "5"}]}}
|
|
91
|
+
B: {"result": {"content": [{"type": "text", "text": "6"}]}}
|
|
92
|
+
context (events …i):
|
|
93
|
+
c2s/request tools/call
|
|
94
|
+
s2c/notification notifications/message
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`diff` exit codes are designed for CI: `0` identical, `1` output drift, `2` structural drift.
|
|
98
|
+
|
|
99
|
+
## In your agent config
|
|
100
|
+
|
|
101
|
+
`rec` is one config edit away from any stdio-based MCP client (Claude Desktop, Claude Code, Cursor, ...):
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"mcpServers": {
|
|
106
|
+
"my-tools": {
|
|
107
|
+
"command": "mcptape",
|
|
108
|
+
"args": ["rec", "--name", "daily-run", "--", "python", "-m", "my_tools"]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
|
|
116
|
+
## Why matching is by content, not position
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
`initialize` is matched leniently on parameters (client name/version/protocol bumps are not behavior drift) and its recorded capabilities answer is served verbatim.
|
|
121
|
+
|
|
122
|
+
## Why not LangSmith / LangFuse / OTel platforms
|
|
123
|
+
|
|
124
|
+
| | Cloud observability | **mcptape** |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| Position | watch runs (dashboard) | **reproduce runs (recorder)** |
|
|
127
|
+
| Replay | re-calls the tools (state changed, money spent) | **replays recorded results (free, deterministic)** |
|
|
128
|
+
| Data | uploaded to a third party | **local files only** |
|
|
129
|
+
| Dependencies | SDK + reporting config | zero, two commands |
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
## CLI
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
mcptape rec --name RUN -- <server command> # proxy + record a live session
|
|
137
|
+
mcptape play RUN [--lenient] # serve a recording as an MCP server
|
|
138
|
+
mcptape diff A B # first drift + context; exit 0/1/2
|
|
139
|
+
mcptape ls # list recorded runs
|
|
140
|
+
mcptape report RUN # per-message table
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+

|
|
144
|
+
|
|
145
|
+
## Trace format
|
|
146
|
+
|
|
147
|
+
One directory per run, two plain files:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
.mcptape/runs/20261031-011921-demo-stable/
|
|
151
|
+
meta.json # server command, protocol version, counts
|
|
152
|
+
trace.jsonl # one JSON-RPC message per line, with request echoes
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{"v": 1, "i": 10, "dir": "s2c", "kind": "response", "id": 4,
|
|
157
|
+
"payload": {"result": {"content": [{"type": "text", "text": "5"}]}},
|
|
158
|
+
"for_method": "tools/call", "for_params": {"name": "add", "arguments": {"a": 2, "b": 3}},
|
|
159
|
+
"wall_ms": 0.9}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
## Honest boundaries
|
|
165
|
+
|
|
166
|
+
- **stdio transport only (v0.1).** HTTP/SSE-based MCP servers are not wrapped yet; streamable-HTTP support is on the roadmap.
|
|
167
|
+
- **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.
|
|
168
|
+
- **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.
|
|
169
|
+
- **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.
|
|
170
|
+
- **No monkeypatching.** `rec` is a proxy process; the client config opts in explicitly.
|
|
171
|
+
|
|
172
|
+
## Roadmap
|
|
173
|
+
|
|
174
|
+
- [ ] streamable-HTTP transport wrapping
|
|
175
|
+
- [ ] `--subset` replay (serve only named tools from the tape, live for the rest)
|
|
176
|
+
- [ ] trace signing (tamper-evident hash chain)
|
|
177
|
+
- [ ] OTel GenAI span import/export
|
|
178
|
+
|
|
179
|
+
## The tape suite
|
|
180
|
+
|
|
181
|
+
The same "record once, replay forever" idea, at three layers:
|
|
182
|
+
|
|
183
|
+
- **[tapedeck](https://github.com/EAnge1o/tapedeck)** — model & tool calls *inside* your agent (Python, PyPI)
|
|
184
|
+
- **mcptape** — the MCP tool-protocol boundary (this repo)
|
|
185
|
+
- **[mocktape](https://github.com/EAnge1o/mocktape)** — HTTP traffic behind your frontend (TypeScript, npm)
|
|
186
|
+
|
|
187
|
+
Design trade-offs live in the source docstrings; every image in this README is rendered from real command output.
|
mcptape-0.1.0/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# mcptape
|
|
2
|
+
|
|
3
|
+
<img src="docs/logo.svg" alt="mcptape" width="420" align="center" />
|
|
4
|
+
|
|
5
|
+
[](https://github.com/EAnge1o/mcptape/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/mcptape/)
|
|
7
|
+
[](https://python.org/)
|
|
8
|
+
[]()
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
**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 (⏩).
|
|
12
|
+
|
|
13
|
+
> 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.
|
|
14
|
+
|
|
15
|
+
Zero dependencies: pure Python standard library. Nothing leaves your machine — a trace is a local file.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
*(The screenshot is rendered from real command output by `docs/make_demo_svg.py` — not staged.)*
|
|
20
|
+
|
|
21
|
+
中文文档:[README.zh-CN.md](README.zh-CN.md)
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
```mermaid
|
|
26
|
+
flowchart LR
|
|
27
|
+
A["MCP client<br/>(your agent)"] -->|"stdio"| B["mcptape rec<br/>transparent proxy ⏺"]
|
|
28
|
+
B -->|"stdio"| C["real MCP server"]
|
|
29
|
+
B --> D[".mcptape/runs/<br/>trace.jsonl + meta.json"]
|
|
30
|
+
A -->|"stdio"| E["mcptape play<br/>served from tape ▶"]
|
|
31
|
+
D -.->|"byte-for-byte"| E
|
|
32
|
+
F["second recording"] --> G["mcptape diff<br/>first drifted call"]
|
|
33
|
+
E -.-> G
|
|
34
|
+
G -->|"exit 0 / 1 / 2"| H["CI ✅ ❌"]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
## 30 seconds
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install mcptape
|
|
45
|
+
|
|
46
|
+
# 1. record a session: put mcptape between your client and any MCP server
|
|
47
|
+
python examples/demo_client.py -- mcptape rec --name demo-stable -- python examples/demo_server.py
|
|
48
|
+
|
|
49
|
+
# 2. replay it offline -- same results, no server process, $0.00
|
|
50
|
+
python examples/demo_client.py -- mcptape play demo-stable
|
|
51
|
+
|
|
52
|
+
# 3. the server changes; record again and diff
|
|
53
|
+
MCPTAPE_DEMO_DRIFT=1 python examples/demo_client.py -- mcptape rec --name demo-drift -- python examples/demo_server.py
|
|
54
|
+
mcptape diff demo-stable demo-drift
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Real output (paste the commands and you get this verbatim):
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
=== replay ===
|
|
61
|
+
server: demo-tools
|
|
62
|
+
tools: echo, add
|
|
63
|
+
echo: tapedeck, but for MCP
|
|
64
|
+
add(2, 3): 5
|
|
65
|
+
|
|
66
|
+
=== diff demo-stable demo-drift ===
|
|
67
|
+
run A: demo-stable (11 events)
|
|
68
|
+
run B: demo-drift (11 events)
|
|
69
|
+
event 10 (s2c response):
|
|
70
|
+
result changed
|
|
71
|
+
A: {"result": {"content": [{"type": "text", "text": "5"}]}}
|
|
72
|
+
B: {"result": {"content": [{"type": "text", "text": "6"}]}}
|
|
73
|
+
context (events …i):
|
|
74
|
+
c2s/request tools/call
|
|
75
|
+
s2c/notification notifications/message
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`diff` exit codes are designed for CI: `0` identical, `1` output drift, `2` structural drift.
|
|
79
|
+
|
|
80
|
+
## In your agent config
|
|
81
|
+
|
|
82
|
+
`rec` is one config edit away from any stdio-based MCP client (Claude Desktop, Claude Code, Cursor, ...):
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mcpServers": {
|
|
87
|
+
"my-tools": {
|
|
88
|
+
"command": "mcptape",
|
|
89
|
+
"args": ["rec", "--name", "daily-run", "--", "python", "-m", "my_tools"]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## Why matching is by content, not position
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
`initialize` is matched leniently on parameters (client name/version/protocol bumps are not behavior drift) and its recorded capabilities answer is served verbatim.
|
|
102
|
+
|
|
103
|
+
## Why not LangSmith / LangFuse / OTel platforms
|
|
104
|
+
|
|
105
|
+
| | Cloud observability | **mcptape** |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| Position | watch runs (dashboard) | **reproduce runs (recorder)** |
|
|
108
|
+
| Replay | re-calls the tools (state changed, money spent) | **replays recorded results (free, deterministic)** |
|
|
109
|
+
| Data | uploaded to a third party | **local files only** |
|
|
110
|
+
| Dependencies | SDK + reporting config | zero, two commands |
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
## CLI
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
mcptape rec --name RUN -- <server command> # proxy + record a live session
|
|
118
|
+
mcptape play RUN [--lenient] # serve a recording as an MCP server
|
|
119
|
+
mcptape diff A B # first drift + context; exit 0/1/2
|
|
120
|
+
mcptape ls # list recorded runs
|
|
121
|
+
mcptape report RUN # per-message table
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+
## Trace format
|
|
127
|
+
|
|
128
|
+
One directory per run, two plain files:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
.mcptape/runs/20261031-011921-demo-stable/
|
|
132
|
+
meta.json # server command, protocol version, counts
|
|
133
|
+
trace.jsonl # one JSON-RPC message per line, with request echoes
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{"v": 1, "i": 10, "dir": "s2c", "kind": "response", "id": 4,
|
|
138
|
+
"payload": {"result": {"content": [{"type": "text", "text": "5"}]}},
|
|
139
|
+
"for_method": "tools/call", "for_params": {"name": "add", "arguments": {"a": 2, "b": 3}},
|
|
140
|
+
"wall_ms": 0.9}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
## Honest boundaries
|
|
146
|
+
|
|
147
|
+
- **stdio transport only (v0.1).** HTTP/SSE-based MCP servers are not wrapped yet; streamable-HTTP support is on the roadmap.
|
|
148
|
+
- **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.
|
|
149
|
+
- **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.
|
|
150
|
+
- **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.
|
|
151
|
+
- **No monkeypatching.** `rec` is a proxy process; the client config opts in explicitly.
|
|
152
|
+
|
|
153
|
+
## Roadmap
|
|
154
|
+
|
|
155
|
+
- [ ] streamable-HTTP transport wrapping
|
|
156
|
+
- [ ] `--subset` replay (serve only named tools from the tape, live for the rest)
|
|
157
|
+
- [ ] trace signing (tamper-evident hash chain)
|
|
158
|
+
- [ ] OTel GenAI span import/export
|
|
159
|
+
|
|
160
|
+
## The tape suite
|
|
161
|
+
|
|
162
|
+
The same "record once, replay forever" idea, at three layers:
|
|
163
|
+
|
|
164
|
+
- **[tapedeck](https://github.com/EAnge1o/tapedeck)** — model & tool calls *inside* your agent (Python, PyPI)
|
|
165
|
+
- **mcptape** — the MCP tool-protocol boundary (this repo)
|
|
166
|
+
- **[mocktape](https://github.com/EAnge1o/mocktape)** — HTTP traffic behind your frontend (TypeScript, npm)
|
|
167
|
+
|
|
168
|
+
Design trade-offs live in the source docstrings; every image in this README is rendered from real command output.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""mcptape -- a tape recorder for the Model Context Protocol.
|
|
2
|
+
|
|
3
|
+
Wrap any MCP server with ``mcptape rec`` and its session lands on a local
|
|
4
|
+
JSONL tape; replay it later with ``mcptape play`` and every tool call is
|
|
5
|
+
served from the tape -- offline, free, byte-identical. Diff two recordings
|
|
6
|
+
to find the first call where behavior drifted.
|
|
7
|
+
|
|
8
|
+
mcptape rec --name run1 -- python my_mcp_server.py
|
|
9
|
+
mcptape play run1
|
|
10
|
+
mcptape diff run1 run2
|
|
11
|
+
|
|
12
|
+
Zero dependencies: pure Python standard library. Sister of tapedeck
|
|
13
|
+
(model calls) and mocktape (HTTP traffic) -- one metaphor per layer.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .diff import diff_runs
|
|
17
|
+
from .proxy import RecordingProxy
|
|
18
|
+
from .replay import ReplayingServer
|
|
19
|
+
from .trace import DEFAULT_ROOT, Event, RunStore
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"RecordingProxy", "ReplayingServer", "Event", "RunStore", "diff_runs",
|
|
25
|
+
"DEFAULT_ROOT", "__version__",
|
|
26
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""``mcptape`` CLI: rec / play / diff / ls / report.
|
|
2
|
+
|
|
3
|
+
mcptape rec --name run1 -- python my_mcp_server.py # tape a live session
|
|
4
|
+
mcptape play run1 # serve it offline
|
|
5
|
+
mcptape diff run1 run2 # first drift, exit-coded
|
|
6
|
+
|
|
7
|
+
``rec`` is a transparent proxy: the MCP client talks to mcptape's stdio,
|
|
8
|
+
mcptape talks to the real server spawned behind ``--``. ``play`` replaces
|
|
9
|
+
the server entirely -- no child, no network, byte-identical results.
|
|
10
|
+
|
|
11
|
+
Exit codes on ``play``: 0 clean, 1 drift, 2 structural/unreplayable.
|
|
12
|
+
Exit codes on ``diff``: 0 same, 1 output drift, 2 structural drift.
|
|
13
|
+
Everything human-readable goes to stderr; stdout is the MCP wire.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .diff import EXIT_OUTPUT_DRIFT, EXIT_SAME, EXIT_STRUCTURE_DRIFT, diff_runs
|
|
24
|
+
from .proxy import RecordingProxy
|
|
25
|
+
from .replay import ReplayingServer
|
|
26
|
+
from .trace import DEFAULT_ROOT, RunStore
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _LineWriter:
|
|
30
|
+
"""Binary-stream writer: encodes text as UTF-8 without touching line endings.
|
|
31
|
+
|
|
32
|
+
Callers (proxy/replay ``_send``) already append exactly one ``\\n``; adding
|
|
33
|
+
another here would double-space the MCP wire on every message.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, buffer):
|
|
37
|
+
self.buffer = buffer
|
|
38
|
+
|
|
39
|
+
def write(self, text: str) -> None:
|
|
40
|
+
self.buffer.write(text.encode("utf-8"))
|
|
41
|
+
self.buffer.flush()
|
|
42
|
+
|
|
43
|
+
def flush(self) -> None:
|
|
44
|
+
self.buffer.flush()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="mcptape",
|
|
50
|
+
description="Tape recorder for the Model Context Protocol: record once, "
|
|
51
|
+
"replay forever, diff what drifted.")
|
|
52
|
+
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT,
|
|
53
|
+
help=f"run storage root (default {DEFAULT_ROOT})")
|
|
54
|
+
parser.add_argument("--version", action="version", version=f"mcptape {__version__}")
|
|
55
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
p_rec = sub.add_parser("rec", help="proxy a live MCP server and record the session")
|
|
58
|
+
p_rec.add_argument("--name", default="session")
|
|
59
|
+
p_rec.add_argument("server_command", nargs=argparse.REMAINDER,
|
|
60
|
+
help="server command to spawn, after --")
|
|
61
|
+
|
|
62
|
+
p_play = sub.add_parser("play", help="serve a recorded session as an MCP server")
|
|
63
|
+
p_play.add_argument("run")
|
|
64
|
+
p_play.add_argument("--lenient", action="store_true",
|
|
65
|
+
help="on drift, serve the next recorded exchange (debugging only)")
|
|
66
|
+
|
|
67
|
+
p_diff = sub.add_parser("diff", help="compare two runs; exit 1 output drift, 2 structural")
|
|
68
|
+
p_diff.add_argument("a")
|
|
69
|
+
p_diff.add_argument("b")
|
|
70
|
+
p_diff.add_argument("--context", type=int, default=2)
|
|
71
|
+
|
|
72
|
+
sub.add_parser("ls", help="list recorded runs")
|
|
73
|
+
|
|
74
|
+
p_report = sub.add_parser("report", help="per-message table for one run")
|
|
75
|
+
p_report.add_argument("run")
|
|
76
|
+
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_rec(args) -> int:
|
|
81
|
+
command = [c for c in args.server_command if c != "--"]
|
|
82
|
+
if not command:
|
|
83
|
+
print("error: no server command given after --", file=sys.stderr)
|
|
84
|
+
return EXIT_STRUCTURE_DRIFT
|
|
85
|
+
proxy = RecordingProxy(sys.stdin, _LineWriter(sys.stdout.buffer), command)
|
|
86
|
+
try:
|
|
87
|
+
code = proxy.run(root=args.root)
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
code = 130
|
|
90
|
+
run_dir = proxy.save(root=args.root, name=args.name)
|
|
91
|
+
exchanges = sum(1 for e in proxy.events if e.kind == "request" and e.dir == "c2s")
|
|
92
|
+
print(f"[recorded {len(proxy.events)} messages -> {run_dir} | {exchanges} exchanges]",
|
|
93
|
+
file=sys.stderr)
|
|
94
|
+
return code if code is not None else 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_play(args) -> int:
|
|
98
|
+
store = RunStore(args.root)
|
|
99
|
+
run_dir = store.resolve(args.run)
|
|
100
|
+
events, meta = RunStore.read_run(run_dir)
|
|
101
|
+
server = ReplayingServer(sys.stdin, _LineWriter(sys.stdout.buffer), events,
|
|
102
|
+
lenient=args.lenient, label=meta.get("name", args.run))
|
|
103
|
+
return server.serve()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_diff(args) -> int:
|
|
107
|
+
store = RunStore(args.root)
|
|
108
|
+
a_dir, b_dir = store.resolve(args.a), store.resolve(args.b)
|
|
109
|
+
events_a, meta_a = RunStore.read_run(a_dir)
|
|
110
|
+
events_b, meta_b = RunStore.read_run(b_dir)
|
|
111
|
+
result = diff_runs(events_a, events_b, meta_a, meta_b, context=args.context)
|
|
112
|
+
print("\n".join(result["lines"]))
|
|
113
|
+
if result["same"]:
|
|
114
|
+
return EXIT_SAME
|
|
115
|
+
return EXIT_STRUCTURE_DRIFT if result["structural"] else EXIT_OUTPUT_DRIFT
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_ls(args) -> int:
|
|
119
|
+
store = RunStore(args.root)
|
|
120
|
+
runs = store.list_runs()
|
|
121
|
+
if not runs:
|
|
122
|
+
print(f"no runs under {store.root}")
|
|
123
|
+
return 0
|
|
124
|
+
width = max(len(r.name) for r in runs) + 2
|
|
125
|
+
print(f"{'run':<{width}} {'exchanges':>9} {'messages':>8} name")
|
|
126
|
+
for run_dir in runs:
|
|
127
|
+
try:
|
|
128
|
+
events, meta = RunStore.read_run(run_dir)
|
|
129
|
+
except (ValueError, OSError):
|
|
130
|
+
continue
|
|
131
|
+
exchanges = sum(1 for e in events if e.kind == "request" and e.dir == "c2s")
|
|
132
|
+
print(f"{run_dir.name:<{width}} {exchanges:>9} {len(events):>8} {meta.get('name', '')}")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_report(args) -> int:
|
|
137
|
+
store = RunStore(args.root)
|
|
138
|
+
run_dir = store.resolve(args.run)
|
|
139
|
+
events, meta = RunStore.read_run(run_dir)
|
|
140
|
+
print(f"run {run_dir.name}")
|
|
141
|
+
print(f" server: {' '.join(meta.get('server_command', [])) or '?'} "
|
|
142
|
+
f"protocol: {meta.get('protocol_version', '?')}")
|
|
143
|
+
print(f" {'#':>3} {'dir':<4} {'kind':<13} {'method':<28} usage")
|
|
144
|
+
for e in events:
|
|
145
|
+
name = e.method or (e.for_method or "")
|
|
146
|
+
print(f" {e.i:>3} {e.dir:<4} {e.kind:<13} {name:<28}"
|
|
147
|
+
+ (f" id={e.id}" if e.id is not None else ""))
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(argv: list[str] | None = None) -> int:
|
|
152
|
+
args = build_parser().parse_args(argv)
|
|
153
|
+
if args.command == "rec":
|
|
154
|
+
return cmd_rec(args)
|
|
155
|
+
if args.command == "play":
|
|
156
|
+
return cmd_play(args)
|
|
157
|
+
if args.command == "diff":
|
|
158
|
+
return cmd_diff(args)
|
|
159
|
+
if args.command == "ls":
|
|
160
|
+
return cmd_ls(args)
|
|
161
|
+
if args.command == "report":
|
|
162
|
+
return cmd_report(args)
|
|
163
|
+
return 2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Diff two recorded MCP sessions: the first divergence, with context.
|
|
2
|
+
|
|
3
|
+
Position-based comparison (two recordings of the same scripted session line
|
|
4
|
+
up step for step, like tapedeck's runs). Exit codes are the CI contract:
|
|
5
|
+
|
|
6
|
+
* ``0`` -- identical sessions;
|
|
7
|
+
* ``1`` -- output drift: same call at the same position, different
|
|
8
|
+
arguments or a different result;
|
|
9
|
+
* ``2`` -- structural drift: sessions have different lengths, or the same
|
|
10
|
+
position holds different kinds/methods (calls added, removed, reordered).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
EXIT_SAME = 0
|
|
17
|
+
EXIT_OUTPUT_DRIFT = 1
|
|
18
|
+
EXIT_STRUCTURE_DRIFT = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _short(value, limit: int = 120) -> str:
|
|
22
|
+
text = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else value
|
|
23
|
+
return text if len(text) <= limit else text[:limit] + "…"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def diff_runs(events_a, events_b, meta_a: dict, meta_b: dict, context: int = 2) -> dict:
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
lines.append(f"run A: {meta_a.get('name', '?')} ({len(events_a)} events)")
|
|
29
|
+
lines.append(f"run B: {meta_b.get('name', '?')} ({len(events_b)} events)")
|
|
30
|
+
|
|
31
|
+
result = {"same": True, "structural": False, "first_divergence": None, "reason": None,
|
|
32
|
+
"lines": lines}
|
|
33
|
+
|
|
34
|
+
def divergence(i: int, why: str, detail: list[str], structural: bool) -> None:
|
|
35
|
+
result["same"] = False
|
|
36
|
+
result["first_divergence"] = i
|
|
37
|
+
result["reason"] = why
|
|
38
|
+
result["structural"] = structural
|
|
39
|
+
lines.append(f"event {i} ({events_a[i].dir} {events_a[i].kind}):")
|
|
40
|
+
lines.append(f" {why}")
|
|
41
|
+
lines.extend(detail)
|
|
42
|
+
ctx = [f" {e.dir}/{e.kind} {e.method or e.for_method or ''}" for e in
|
|
43
|
+
events_a[max(0, i - context):i]]
|
|
44
|
+
if ctx:
|
|
45
|
+
lines.append(" context (events …i):")
|
|
46
|
+
lines.extend(ctx)
|
|
47
|
+
|
|
48
|
+
n = max(len(events_a), len(events_b))
|
|
49
|
+
for i in range(n):
|
|
50
|
+
if i >= len(events_a) or i >= len(events_b):
|
|
51
|
+
longer = "A" if len(events_a) > len(events_b) else "B"
|
|
52
|
+
divergence(min(i, len(events_a) - 1) if events_a else 0,
|
|
53
|
+
"structural",
|
|
54
|
+
[f" run {longer} continues past the other "
|
|
55
|
+
f"({abs(len(events_a) - len(events_b))} extra event(s))"],
|
|
56
|
+
structural=True)
|
|
57
|
+
break
|
|
58
|
+
a, b = events_a[i], events_b[i]
|
|
59
|
+
if (a.dir, a.kind, a.method) != (b.dir, b.kind, b.method):
|
|
60
|
+
divergence(i, "structural",
|
|
61
|
+
[f" A: {a.dir} {a.kind} {a.method or ''}",
|
|
62
|
+
f" B: {b.dir} {b.kind} {b.method or ''}"],
|
|
63
|
+
structural=True)
|
|
64
|
+
break
|
|
65
|
+
if a.kind == "request":
|
|
66
|
+
if not _eq(a.params, b.params):
|
|
67
|
+
divergence(i, "arguments changed",
|
|
68
|
+
[f" A: params {_short(a.params)}",
|
|
69
|
+
f" B: params {_short(b.params)}"], structural=False)
|
|
70
|
+
break
|
|
71
|
+
elif a.kind == "response":
|
|
72
|
+
if not _eq(a.payload, b.payload):
|
|
73
|
+
divergence(i, "result changed",
|
|
74
|
+
[f" A: {_short(a.payload)}",
|
|
75
|
+
f" B: {_short(b.payload)}"], structural=False)
|
|
76
|
+
break
|
|
77
|
+
elif a.kind == "notification":
|
|
78
|
+
if not _eq(a.params, b.params):
|
|
79
|
+
divergence(i, "notification changed",
|
|
80
|
+
[f" A: {_short(a.params)}",
|
|
81
|
+
f" B: {_short(b.params)}"], structural=False)
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
if result["same"]:
|
|
85
|
+
lines.append("sessions are identical")
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _eq(a, b) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
return json.loads(json.dumps(a)) == json.loads(json.dumps(b))
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return a == b
|