mcptape 0.1.0__py3-none-any.whl
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/__init__.py +26 -0
- mcptape/__main__.py +7 -0
- mcptape/cli.py +167 -0
- mcptape/diff.py +93 -0
- mcptape/proxy.py +187 -0
- mcptape/replay.py +169 -0
- mcptape/trace.py +149 -0
- mcptape-0.1.0.dist-info/METADATA +187 -0
- mcptape-0.1.0.dist-info/RECORD +13 -0
- mcptape-0.1.0.dist-info/WHEEL +5 -0
- mcptape-0.1.0.dist-info/entry_points.txt +2 -0
- mcptape-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcptape-0.1.0.dist-info/top_level.txt +1 -0
mcptape/__init__.py
ADDED
|
@@ -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
|
+
]
|
mcptape/__main__.py
ADDED
mcptape/cli.py
ADDED
|
@@ -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())
|
mcptape/diff.py
ADDED
|
@@ -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
|
mcptape/proxy.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""REC mode: a transparent recording proxy between an MCP client and a server.
|
|
2
|
+
|
|
3
|
+
MCP client ⇄ mcptape rec ⇄ real MCP server
|
|
4
|
+
|
|
5
|
+
mcptape spawns the real server as a child process and pumps JSON-RPC lines
|
|
6
|
+
both ways unchanged. Every message is filed to the tape:
|
|
7
|
+
|
|
8
|
+
* client→server requests are recorded with their ``params``;
|
|
9
|
+
* each response is recorded with an echo of the request it answers (looked
|
|
10
|
+
up by JSON-RPC ``id``), so every trace line is self-contained;
|
|
11
|
+
* server→client notifications are recorded in place and re-emitted on
|
|
12
|
+
replay; server→client *requests* (sampling, elicitation, ...) are passed
|
|
13
|
+
through and recorded too -- but a session containing them cannot be
|
|
14
|
+
replayed (the tape would have to impersonate the client), which replay
|
|
15
|
+
reports up front.
|
|
16
|
+
|
|
17
|
+
Tracing must never break a session: a malformed line is forwarded untouched
|
|
18
|
+
and counted as a warning, and the trace is saved even if the session dies
|
|
19
|
+
mid-flight. The child's stderr is inherited, not recorded (servers log
|
|
20
|
+
there; logs are not the tape).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
from .trace import Event, RunStore
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def classify(msg: dict) -> tuple[str, str]:
|
|
34
|
+
"""(direction-agnostic kind, method) of a parsed JSON-RPC message.
|
|
35
|
+
|
|
36
|
+
request: has method + id · notification: method, no id · response: id, no method.
|
|
37
|
+
"""
|
|
38
|
+
if "method" in msg:
|
|
39
|
+
return ("request", msg["method"]) if "id" in msg else ("notification", msg["method"])
|
|
40
|
+
return ("response", None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RecordingProxy:
|
|
44
|
+
"""Pump lines between client streams and the child server, filing the tape."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, client_in, client_out, command: list[str]):
|
|
47
|
+
self.client_in, self.client_out = client_in, client_out
|
|
48
|
+
self.command = list(command)
|
|
49
|
+
self.events: list[Event] = []
|
|
50
|
+
self.lock = threading.Lock()
|
|
51
|
+
self.pending_c2s: dict = {} # id -> (method, params, started)
|
|
52
|
+
self.pending_s2c: dict = {} # id -> (method, params)
|
|
53
|
+
self.warnings = 0
|
|
54
|
+
self.protocol_version = None
|
|
55
|
+
self.server_info = None
|
|
56
|
+
self.client_info = None
|
|
57
|
+
self._child = None
|
|
58
|
+
|
|
59
|
+
# -- tape helpers ------------------------------------------------------
|
|
60
|
+
def _append(self, event: Event) -> None:
|
|
61
|
+
with self.lock:
|
|
62
|
+
event.i = len(self.events)
|
|
63
|
+
self.events.append(event)
|
|
64
|
+
|
|
65
|
+
def _record(self, *, dir: str, kind: str, method=None,
|
|
66
|
+
id=None, params=None, payload=None, for_method=None,
|
|
67
|
+
for_params=None, wall_ms=0.0) -> None:
|
|
68
|
+
self._append(Event(dir=dir, kind=kind, method=method, id=id,
|
|
69
|
+
params=params, payload=payload, for_method=for_method,
|
|
70
|
+
for_params=for_params, wall_ms=wall_ms))
|
|
71
|
+
|
|
72
|
+
# -- pumps -------------------------------------------------------------
|
|
73
|
+
def run(self, root=None) -> int:
|
|
74
|
+
"""Run the session; returns the child's exit code."""
|
|
75
|
+
self._child = subprocess.Popen(
|
|
76
|
+
self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
77
|
+
stderr=None, encoding="utf-8")
|
|
78
|
+
reader = threading.Thread(target=self._pump_server_to_client, daemon=True)
|
|
79
|
+
reader.start()
|
|
80
|
+
try:
|
|
81
|
+
self._pump_client_to_server()
|
|
82
|
+
finally:
|
|
83
|
+
try:
|
|
84
|
+
self._child.stdin.close()
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
pass
|
|
87
|
+
try:
|
|
88
|
+
self._child.wait(timeout=10)
|
|
89
|
+
except subprocess.TimeoutExpired:
|
|
90
|
+
self._child.kill()
|
|
91
|
+
reader.join(timeout=5)
|
|
92
|
+
return self._child.returncode if self._child.returncode is not None else 0
|
|
93
|
+
|
|
94
|
+
def _send(self, line: str, stream) -> None:
|
|
95
|
+
stream.write(line + "\n")
|
|
96
|
+
stream.flush()
|
|
97
|
+
|
|
98
|
+
def _pump_client_to_server(self) -> None:
|
|
99
|
+
for raw in self.client_in:
|
|
100
|
+
line = raw.rstrip("\r\n")
|
|
101
|
+
if not line.strip():
|
|
102
|
+
continue
|
|
103
|
+
started = time.perf_counter()
|
|
104
|
+
msg = None
|
|
105
|
+
try:
|
|
106
|
+
msg = json.loads(line)
|
|
107
|
+
except ValueError:
|
|
108
|
+
with self.lock:
|
|
109
|
+
self.warnings += 1
|
|
110
|
+
if isinstance(msg, dict):
|
|
111
|
+
kind, method = classify(msg)
|
|
112
|
+
if kind == "request":
|
|
113
|
+
with self.lock:
|
|
114
|
+
self.pending_c2s[msg.get("id")] = (method, msg.get("params"), started)
|
|
115
|
+
if method == "initialize":
|
|
116
|
+
self.client_info = (msg.get("params") or {}).get("clientInfo")
|
|
117
|
+
self._record(dir="c2s", kind="request", method=method,
|
|
118
|
+
id=msg.get("id"), params=msg.get("params"),
|
|
119
|
+
wall_ms=0.0)
|
|
120
|
+
elif kind == "response":
|
|
121
|
+
with self.lock:
|
|
122
|
+
origin = self.pending_s2c.pop(msg.get("id"), None)
|
|
123
|
+
self._record(dir="c2s", kind="response", id=msg.get("id"),
|
|
124
|
+
payload={k: msg[k] for k in ("result", "error") if k in msg},
|
|
125
|
+
for_method=origin[0] if origin else None,
|
|
126
|
+
for_params=origin[1] if origin else None,
|
|
127
|
+
wall_ms=(time.perf_counter() - started) * 1000)
|
|
128
|
+
else:
|
|
129
|
+
self._record(dir="c2s", kind="notification", method=method,
|
|
130
|
+
params=msg.get("params"))
|
|
131
|
+
self._send(line, self._child.stdin)
|
|
132
|
+
# client half-closed: let the server finish and exit on its own
|
|
133
|
+
|
|
134
|
+
def _pump_server_to_client(self) -> None:
|
|
135
|
+
child_out = self._child.stdout
|
|
136
|
+
for raw in child_out:
|
|
137
|
+
line = raw.rstrip("\r\n")
|
|
138
|
+
if not line.strip():
|
|
139
|
+
continue
|
|
140
|
+
msg = None
|
|
141
|
+
try:
|
|
142
|
+
msg = json.loads(line)
|
|
143
|
+
except ValueError:
|
|
144
|
+
with self.lock:
|
|
145
|
+
self.warnings += 1
|
|
146
|
+
if isinstance(msg, dict):
|
|
147
|
+
kind, method = classify(msg)
|
|
148
|
+
if kind == "response":
|
|
149
|
+
with self.lock:
|
|
150
|
+
origin = self.pending_c2s.pop(msg.get("id"), None)
|
|
151
|
+
if origin:
|
|
152
|
+
rm, params, started = origin
|
|
153
|
+
if rm == "initialize":
|
|
154
|
+
result = msg.get("result") or {}
|
|
155
|
+
self.protocol_version = result.get("protocolVersion")
|
|
156
|
+
self.server_info = result.get("serverInfo")
|
|
157
|
+
self._record(dir="s2c", kind="response", id=msg.get("id"),
|
|
158
|
+
payload={k: msg[k] for k in ("result", "error") if k in msg},
|
|
159
|
+
for_method=rm, for_params=params,
|
|
160
|
+
wall_ms=(time.perf_counter() - started) * 1000)
|
|
161
|
+
else: # orphan response -- keep it on tape, unexplained
|
|
162
|
+
self._record(dir="s2c", kind="response", id=msg.get("id"),
|
|
163
|
+
payload={k: msg[k] for k in ("result", "error") if k in msg})
|
|
164
|
+
elif kind == "request": # server asks the client (sampling, ...)
|
|
165
|
+
with self.lock:
|
|
166
|
+
self.pending_s2c[msg.get("id")] = (method, msg.get("params"))
|
|
167
|
+
self._record(dir="s2c", kind="request", method=method,
|
|
168
|
+
id=msg.get("id"), params=msg.get("params"))
|
|
169
|
+
else:
|
|
170
|
+
self._record(dir="s2c", kind="notification", method=method,
|
|
171
|
+
params=msg.get("params"))
|
|
172
|
+
self._send(line, self.client_out)
|
|
173
|
+
|
|
174
|
+
# -- persistence -------------------------------------------------------
|
|
175
|
+
def save(self, root=None, name: str = "session") -> str:
|
|
176
|
+
store = RunStore(root) if root else RunStore()
|
|
177
|
+
run_dir = store.new_run_dir(name)
|
|
178
|
+
RunStore.write_run(run_dir, self.events, {
|
|
179
|
+
"name": name,
|
|
180
|
+
"server_command": self.command,
|
|
181
|
+
"protocol_version": self.protocol_version,
|
|
182
|
+
"server_info": self.server_info,
|
|
183
|
+
"client_info": self.client_info,
|
|
184
|
+
"exchanges": sum(1 for e in self.events if e.kind == "request" and e.dir == "c2s"),
|
|
185
|
+
"warnings": self.warnings,
|
|
186
|
+
})
|
|
187
|
+
return str(run_dir)
|
mcptape/replay.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""PLAY mode: serve a recorded MCP session without any live server.
|
|
2
|
+
|
|
3
|
+
MCP client ⇄ mcptape play ⇄ the tape (no child process)
|
|
4
|
+
|
|
5
|
+
The replaying side answers each incoming client request with the recorded
|
|
6
|
+
response, re-emitting the server notifications that were recorded inside
|
|
7
|
+
that exchange. Responses are served with the *incoming* request's id --
|
|
8
|
+
client ids may differ between the recorded and replayed session, results
|
|
9
|
+
may not.
|
|
10
|
+
|
|
11
|
+
Matching is **by content, not position**: MCP clients pipeline and run tool
|
|
12
|
+
calls in parallel, so positional alignment (tapedeck's rule) would
|
|
13
|
+
false-positive. A request matches the earliest *unconsumed* tape exchange
|
|
14
|
+
with the same method and JSON-equal params; matched exchanges are marked
|
|
15
|
+
consumed, so identical repeated calls consume identical repeated tape
|
|
16
|
+
entries in order. ``initialize`` is matched leniently on params -- client
|
|
17
|
+
name/version/protocol bumps are not behavior drift, and the recorded
|
|
18
|
+
capabilities answer is still served verbatim.
|
|
19
|
+
|
|
20
|
+
Exit codes (CI contract, same as tapedeck): 0 clean replay, 1 content
|
|
21
|
+
drift (a call's arguments matched no recorded exchange), 2 structural
|
|
22
|
+
(unreached recorded exchanges, or a recording that cannot be replayed at
|
|
23
|
+
all -- e.g. it contains server-initiated requests).
|
|
24
|
+
|
|
25
|
+
Everything human-readable goes to **stderr**; stdout is the wire.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
from .trace import Event
|
|
33
|
+
|
|
34
|
+
DRIFT_CODE = -32000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _eq(a, b) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(json.dumps(a)) == json.loads(json.dumps(b))
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return a == b
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ReplayingServer:
|
|
45
|
+
def __init__(self, client_in, client_out, events: list[Event],
|
|
46
|
+
lenient: bool = False, label: str = "session"):
|
|
47
|
+
self.client_in, self.client_out = client_in, client_out
|
|
48
|
+
self.events = events
|
|
49
|
+
self.lenient = lenient
|
|
50
|
+
self.label = label
|
|
51
|
+
self.request_indices = [e.i for e in events if e.dir == "c2s" and e.kind == "request"]
|
|
52
|
+
self.consumed: set[int] = set()
|
|
53
|
+
self.emitted_notes: set[int] = set()
|
|
54
|
+
self.served = 0
|
|
55
|
+
self.notifications = 0
|
|
56
|
+
self.drifts: list[str] = []
|
|
57
|
+
|
|
58
|
+
# -- tape queries ------------------------------------------------------
|
|
59
|
+
def _response_of(self, request_i: int) -> int | None:
|
|
60
|
+
req = self.events[request_i]
|
|
61
|
+
for j in range(request_i + 1, len(self.events)):
|
|
62
|
+
e = self.events[j]
|
|
63
|
+
if e.dir == "s2c" and e.kind == "response" and e.id == req.id:
|
|
64
|
+
return j
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def _match(self, method: str, params) -> int | None:
|
|
68
|
+
"""Earliest unconsumed tape request matching this call."""
|
|
69
|
+
for j in self.request_indices:
|
|
70
|
+
if j in self.consumed:
|
|
71
|
+
continue
|
|
72
|
+
e = self.events[j]
|
|
73
|
+
if e.method == method and (method == "initialize" or _eq(e.params, params)):
|
|
74
|
+
return j
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# -- wire helpers ------------------------------------------------------
|
|
78
|
+
def _send(self, obj: dict) -> None:
|
|
79
|
+
self.client_out.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
80
|
+
self.client_out.flush()
|
|
81
|
+
|
|
82
|
+
def _flush_notifications(self, upto: int) -> None:
|
|
83
|
+
"""Emit every recorded s2c notification before ``upto``, in tape order."""
|
|
84
|
+
for k in range(0, min(upto, len(self.events))):
|
|
85
|
+
if k in self.emitted_notes:
|
|
86
|
+
continue
|
|
87
|
+
e = self.events[k]
|
|
88
|
+
if e.dir == "s2c" and e.kind == "notification":
|
|
89
|
+
self._send({"jsonrpc": "2.0", "method": e.method, "params": e.params})
|
|
90
|
+
self.emitted_notes.add(k)
|
|
91
|
+
self.notifications += 1
|
|
92
|
+
|
|
93
|
+
# -- main loop ---------------------------------------------------------
|
|
94
|
+
def serve(self) -> int:
|
|
95
|
+
if any(e.dir == "s2c" and e.kind == "request" for e in self.events):
|
|
96
|
+
print("mcptape: this recording contains server-initiated requests "
|
|
97
|
+
"(sampling / elicitation / roots); replay cannot impersonate the "
|
|
98
|
+
"client side of those. Use `mcptape diff` on such recordings.",
|
|
99
|
+
file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
|
|
102
|
+
for raw in self.client_in:
|
|
103
|
+
line = raw.rstrip("\r\n")
|
|
104
|
+
if not line.strip():
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
msg = json.loads(line)
|
|
108
|
+
except ValueError:
|
|
109
|
+
continue
|
|
110
|
+
if not isinstance(msg, dict):
|
|
111
|
+
continue
|
|
112
|
+
if "method" in msg and "id" in msg:
|
|
113
|
+
kind, method = "request", msg["method"]
|
|
114
|
+
elif "method" in msg:
|
|
115
|
+
kind, method = "notification", msg["method"]
|
|
116
|
+
else:
|
|
117
|
+
kind, method = "response", None
|
|
118
|
+
|
|
119
|
+
if kind == "notification":
|
|
120
|
+
continue # client-side notices need no tape
|
|
121
|
+
if kind == "response":
|
|
122
|
+
print("mcptape: ignoring client response -- recording has no "
|
|
123
|
+
"server-initiated requests", file=sys.stderr)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
j = self._match(method, msg.get("params"))
|
|
127
|
+
if j is None and self.lenient:
|
|
128
|
+
j = next((k for k in self.request_indices if k not in self.consumed), None)
|
|
129
|
+
if j is not None:
|
|
130
|
+
print(f"mcptape: lenient -- serving {self.events[j].method} "
|
|
131
|
+
f"for drifted {method}", file=sys.stderr)
|
|
132
|
+
if j is None:
|
|
133
|
+
detail = f"{method} params={json.dumps(msg.get('params'), ensure_ascii=False)[:160]}"
|
|
134
|
+
self.drifts.append(detail)
|
|
135
|
+
print(f"mcptape: replay drift -- no recorded exchange matches {detail}",
|
|
136
|
+
file=sys.stderr)
|
|
137
|
+
self._send({"jsonrpc": "2.0", "id": msg.get("id"), "error": {
|
|
138
|
+
"code": DRIFT_CODE,
|
|
139
|
+
"message": f"mcptape replay drift: no recorded exchange matches {method}",
|
|
140
|
+
}})
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
response_i = self._response_of(j)
|
|
144
|
+
# every notification recorded by now that has not been replayed
|
|
145
|
+
# yet goes out first -- this matches the live ordering semantics
|
|
146
|
+
self._flush_notifications(response_i if response_i is not None else j)
|
|
147
|
+
payload = (self.events[response_i].payload if response_i is not None else None) \
|
|
148
|
+
or {"result": None}
|
|
149
|
+
self._send({"jsonrpc": "2.0", "id": msg.get("id"), **payload})
|
|
150
|
+
self.consumed.add(j)
|
|
151
|
+
self.served += 1
|
|
152
|
+
|
|
153
|
+
return self._finish()
|
|
154
|
+
|
|
155
|
+
def _finish(self) -> int:
|
|
156
|
+
unconsumed = [j for j in self.request_indices if j not in self.consumed]
|
|
157
|
+
if self.drifts: # drift is the actionable signal for CI
|
|
158
|
+
code = 1
|
|
159
|
+
elif unconsumed:
|
|
160
|
+
first = self.events[unconsumed[0]]
|
|
161
|
+
print(f"mcptape: {len(unconsumed)} recorded exchange(s) were never "
|
|
162
|
+
f"replayed, first: {first.method}", file=sys.stderr)
|
|
163
|
+
code = 2
|
|
164
|
+
else:
|
|
165
|
+
code = 0
|
|
166
|
+
print(f"[replayed {self.label!r}: served {self.served} exchanges, "
|
|
167
|
+
f"{self.notifications} notifications, "
|
|
168
|
+
f"{len(self.drifts)} drift(s), 0 live calls]", file=sys.stderr)
|
|
169
|
+
return code
|
mcptape/trace.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Trace model and run storage.
|
|
2
|
+
|
|
3
|
+
One run per directory, two plain files -- the flight-recorder contract from
|
|
4
|
+
tapedeck, applied to MCP sessions::
|
|
5
|
+
|
|
6
|
+
.mcptape/runs/<timestamp>-<name>/
|
|
7
|
+
meta.json # server command, protocol version, counts
|
|
8
|
+
trace.jsonl # one JSON-RPC message per line, with echoes
|
|
9
|
+
|
|
10
|
+
Event kinds on the tape:
|
|
11
|
+
|
|
12
|
+
* ``request`` -- a JSON-RPC request; ``params`` recorded verbatim.
|
|
13
|
+
* ``response`` -- the matching response; ``payload`` is the raw
|
|
14
|
+
``{"result": ...}`` / ``{"error": {...}}`` object, plus an echo of the
|
|
15
|
+
request's ``method`` and ``params`` so every line is self-contained
|
|
16
|
+
(replay and diff never need to join across lines).
|
|
17
|
+
* ``notification`` -- a JSON-RPC notification, either direction.
|
|
18
|
+
|
|
19
|
+
MCP stdio framing is newline-delimited JSON-RPC (one message per line, no
|
|
20
|
+
embedded newlines) -- the trace stores one *logical* message per line too.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import socket
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = 1
|
|
32
|
+
DEFAULT_ROOT = Path(".mcptape") / "runs"
|
|
33
|
+
|
|
34
|
+
_RUN_DIR_RE = re.compile(r"^\d{8}-\d{6}(?:-\d+)?-(?P<name>.+)$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_name_of(dir_name: str) -> str | None:
|
|
38
|
+
"""Extract the run name from '<stamp>-<name>' (or '<stamp>-<n>-<name>')."""
|
|
39
|
+
match = _RUN_DIR_RE.match(dir_name)
|
|
40
|
+
return match.group("name") if match else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _jsonable(value):
|
|
44
|
+
"""Best-effort JSON encoding so tracing never crashes a live session."""
|
|
45
|
+
try:
|
|
46
|
+
json.dumps(value)
|
|
47
|
+
return value
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return repr(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Event:
|
|
54
|
+
"""One JSON-RPC message on the tape."""
|
|
55
|
+
|
|
56
|
+
i: int = 0 # assigned by the recorder on append
|
|
57
|
+
dir: str = "" # "c2s" | "s2c"
|
|
58
|
+
kind: str = "" # "request" | "response" | "notification"
|
|
59
|
+
method: str | None = None # request / notification
|
|
60
|
+
id: int | str | None = None # request / response
|
|
61
|
+
params: Any = None # request / notification
|
|
62
|
+
payload: Any = None # response: {"result": ...} | {"error": {...}}
|
|
63
|
+
for_method: str | None = None # response echo
|
|
64
|
+
for_params: Any = None # response echo
|
|
65
|
+
wall_ms: float = 0.0
|
|
66
|
+
|
|
67
|
+
def to_json(self) -> str:
|
|
68
|
+
return json.dumps({
|
|
69
|
+
"v": SCHEMA_VERSION, "i": self.i, "dir": self.dir, "kind": self.kind,
|
|
70
|
+
"method": self.method, "id": self.id, "params": _jsonable(self.params),
|
|
71
|
+
"payload": _jsonable(self.payload), "for_method": self.for_method,
|
|
72
|
+
"for_params": _jsonable(self.for_params), "wall_ms": round(self.wall_ms, 3),
|
|
73
|
+
}, ensure_ascii=False)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_json(cls, line: str) -> "Event":
|
|
77
|
+
d = json.loads(line)
|
|
78
|
+
if d.get("v") != SCHEMA_VERSION:
|
|
79
|
+
raise ValueError(f"unsupported trace schema version: {d.get('v')!r}")
|
|
80
|
+
return cls(i=d["i"], dir=d["dir"], kind=d["kind"], method=d.get("method"),
|
|
81
|
+
id=d.get("id"), params=d.get("params"), payload=d.get("payload"),
|
|
82
|
+
for_method=d.get("for_method"), for_params=d.get("for_params"),
|
|
83
|
+
wall_ms=d.get("wall_ms", 0.0))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class RunStore:
|
|
88
|
+
"""Locate, list, read, and write runs under a root (default .mcptape/runs)."""
|
|
89
|
+
|
|
90
|
+
root: Path = DEFAULT_ROOT
|
|
91
|
+
|
|
92
|
+
def new_run_dir(self, name: str) -> Path:
|
|
93
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
94
|
+
run_dir = self.root / f"{stamp}-{name}"
|
|
95
|
+
n = 2
|
|
96
|
+
while run_dir.exists(): # same-second reruns
|
|
97
|
+
run_dir = self.root / f"{stamp}-{n}-{name}"
|
|
98
|
+
n += 1
|
|
99
|
+
run_dir.mkdir(parents=True)
|
|
100
|
+
return run_dir
|
|
101
|
+
|
|
102
|
+
def resolve(self, ref: str) -> Path:
|
|
103
|
+
"""Accept an exact run directory, a path, or a unique name prefix.
|
|
104
|
+
|
|
105
|
+
Prefixes match either the full directory name (``20261031-``) or the
|
|
106
|
+
run name embedded in it (``runA``), whichever the user knows.
|
|
107
|
+
"""
|
|
108
|
+
candidate = Path(ref)
|
|
109
|
+
if (candidate / "trace.jsonl").exists():
|
|
110
|
+
return candidate
|
|
111
|
+
matches = [
|
|
112
|
+
p for p in self.list_runs()
|
|
113
|
+
if p.name.startswith(ref) or (_run_name_of(p.name) or "").startswith(ref)
|
|
114
|
+
]
|
|
115
|
+
if not matches:
|
|
116
|
+
raise FileNotFoundError(f"no run matches {ref!r} under {self.root} "
|
|
117
|
+
f"(found {len(self.list_runs())} runs)")
|
|
118
|
+
if len(matches) > 1:
|
|
119
|
+
names = ", ".join(m.name for m in matches[:5])
|
|
120
|
+
raise ValueError(f"run prefix {ref!r} is ambiguous: {names}")
|
|
121
|
+
return matches[0]
|
|
122
|
+
|
|
123
|
+
def list_runs(self) -> list[Path]:
|
|
124
|
+
if not self.root.is_dir():
|
|
125
|
+
return []
|
|
126
|
+
return sorted(d for d in self.root.iterdir()
|
|
127
|
+
if d.is_dir() and (d / "trace.jsonl").exists())
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def write_run(run_dir: Path, events: list[Event], meta: dict) -> Path:
|
|
131
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
meta = {"schema_version": SCHEMA_VERSION, "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
133
|
+
"host": socket.gethostname(), "python": ".".join(map(str, __import__("sys").version_info[:3])),
|
|
134
|
+
**meta}
|
|
135
|
+
(run_dir / "meta.json").write_text(
|
|
136
|
+
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
137
|
+
with open(run_dir / "trace.jsonl", "w", encoding="utf-8") as f:
|
|
138
|
+
for e in events:
|
|
139
|
+
f.write(e.to_json() + "\n")
|
|
140
|
+
return run_dir
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def read_run(run_dir: Path) -> tuple[list[Event], dict]:
|
|
144
|
+
events = [Event.from_json(line) for line in
|
|
145
|
+
(run_dir / "trace.jsonl").read_text(encoding="utf-8").splitlines()
|
|
146
|
+
if line.strip()]
|
|
147
|
+
meta_path = run_dir / "meta.json"
|
|
148
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
|
|
149
|
+
return events, meta
|
|
@@ -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.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
mcptape/__init__.py,sha256=twoUaXJRyduN8_r3YGV3LNei-qabiOwLe2sATWHM_sw,876
|
|
2
|
+
mcptape/__main__.py,sha256=vItpNJ92gAIHg_Mp6EIWZqD8uv6wDWFp7DV0my4tnC4,136
|
|
3
|
+
mcptape/cli.py,sha256=3pwUJAAIwWGuXQxtoDo0jdA_MBOGs7COQFVWZmvhYx8,6216
|
|
4
|
+
mcptape/diff.py,sha256=4sN9FAsDebhotvZFcyYtilhK4CYoH_swzV0spgLLuzM,3768
|
|
5
|
+
mcptape/proxy.py,sha256=EEFU8xg1naK7XgsFb4_IZ9M_QtbAcRnp1VtLdtYhlF0,8444
|
|
6
|
+
mcptape/replay.py,sha256=y9b7JYxCRiDl_o3D0kpI82lIIKIJSgszJIy26i-9WTg,7307
|
|
7
|
+
mcptape/trace.py,sha256=25_qZ71IaoKrr8dfNV1VN85fsKhE4e77BQjVhzESyw0,5939
|
|
8
|
+
mcptape-0.1.0.dist-info/licenses/LICENSE,sha256=PJ8Bq6AhOU6v_87BCQQ3Sehg9wUXTbQeKLHbdsxXLgc,1065
|
|
9
|
+
mcptape-0.1.0.dist-info/METADATA,sha256=-RL-EWiIiDFGYn8CgJ4seca1mbSDfONLWeLvNI9EzhQ,9073
|
|
10
|
+
mcptape-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
mcptape-0.1.0.dist-info/entry_points.txt,sha256=r68hxNoUwQ42pLjSD8P7rhLLH6zKv4uO3IQKG2IZYUw,45
|
|
12
|
+
mcptape-0.1.0.dist-info/top_level.txt,sha256=QspY_xG-YSXseRxgw4tLLPYWLx60gJewY6z7ejK1X4o,8
|
|
13
|
+
mcptape-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcptape
|