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 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
@@ -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())
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
+ [![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,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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcptape = mcptape.cli:main
@@ -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