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 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
+ [![ci](https://github.com/EAnge1o/mcptape/actions/workflows/ci.yml/badge.svg)](https://github.com/EAnge1o/mcptape/actions/workflows/ci.yml)
25
+ [![PyPI](https://img.shields.io/pypi/v/mcptape)](https://pypi.org/project/mcptape/)
26
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://python.org/)
27
+ [![Dependency](https://img.shields.io/badge/dependencies-0-informational.svg)]()
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ ![mcptape demo: record / replay / diff, rendered from real command output](docs/demo.svg)
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
+ ![mcptape ls and report, rendered from real command output](docs/ls.svg)
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.
@@ -0,0 +1,168 @@
1
+ # mcptape
2
+
3
+ <img src="docs/logo.svg" alt="mcptape" width="420" align="center" />
4
+
5
+ [![ci](https://github.com/EAnge1o/mcptape/actions/workflows/ci.yml/badge.svg)](https://github.com/EAnge1o/mcptape/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/mcptape)](https://pypi.org/project/mcptape/)
7
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://python.org/)
8
+ [![Dependency](https://img.shields.io/badge/dependencies-0-informational.svg)]()
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ ![mcptape demo: record / replay / diff, rendered from real command output](docs/demo.svg)
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
+ ![mcptape ls and report, rendered from real command output](docs/ls.svg)
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,7 @@
1
+ """``python -m mcptape`` -- delegates to the CLI."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -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