commitlayer-mcp-recorder 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.
@@ -0,0 +1,468 @@
1
+ """CommitLayer MCP Recorder: a stdio proxy that records an agent's MCP tool calls as CommitLayer actions.
2
+
3
+ The recorder sits between an MCP client (Claude Desktop, Claude Code, Cursor, an agent framework) and any
4
+ downstream MCP server. Every JSON-RPC message is forwarded unchanged in both directions. When the client
5
+ calls a tool that the mapping file names (``tools/call``), the intent is recorded as a CommitLayer action
6
+ **before** the call is forwarded, so the request time precedes every effect::
7
+
8
+ commitlayer-mcp-recorder --map mapping.json -- npx -y @stripe/mcp --tools=all
9
+
10
+ The downstream result is never treated as proof: CommitLayer verifies from the systems of record. Recording
11
+ is fail-open by default: if the API cannot be reached the call is forwarded anyway and a warning goes to
12
+ stderr. The recorder never blocks, reorders or alters the agent's traffic.
13
+
14
+ Standard library only. ``load_mapping`` reads the mapping file, ``build_action`` turns one ``tools/call`` into
15
+ an action body and ``Recorder`` holds the policy (key, URL, fail-open) and the request ids still waiting for
16
+ a downstream answer.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import sys
24
+ import threading
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ __all__ = [
34
+ "DEFAULT_API_URL",
35
+ "JSONRPC_RECORDING_FAILED",
36
+ "LOG_PREFIX",
37
+ "Decision",
38
+ "Mapping",
39
+ "MappingError",
40
+ "Recorder",
41
+ "RecordingError",
42
+ "ToolMapping",
43
+ "UnresolvedPath",
44
+ "__version__",
45
+ "build_action",
46
+ "load_mapping",
47
+ "log_to_stderr",
48
+ "post_action",
49
+ "resolve_path",
50
+ ]
51
+ __version__ = "0.1.0"
52
+
53
+ DEFAULT_API_URL = "https://api.commitlayer.ai"
54
+ LOG_PREFIX = "commitlayer-recorder: "
55
+ JSONRPC_RECORDING_FAILED = -32000
56
+
57
+ Logger = Callable[[str], None]
58
+ Transport = Callable[[dict[str, Any]], dict[str, Any]]
59
+
60
+ _TEMPLATE_FIELD = re.compile(r"\{([^{}]+)\}")
61
+ _TOOL_STRING_FIELDS = ("action_type", "subject_kind", "action_id", "compensates")
62
+
63
+
64
+ def log_to_stderr(message: str) -> None:
65
+ """Write one diagnostic line to stderr. Every line the recorder emits carries ``LOG_PREFIX``."""
66
+ sys.stderr.write(f"{LOG_PREFIX}{message}\n")
67
+ sys.stderr.flush()
68
+
69
+
70
+ class MappingError(ValueError):
71
+ """The mapping file is missing, unreadable or does not describe tools the recorder can use."""
72
+
73
+
74
+ class UnresolvedPath(LookupError):
75
+ """A path the mapping needs (the subject, a template field) is absent from the ``tools/call`` params."""
76
+
77
+ def __init__(self, path: str):
78
+ super().__init__(path)
79
+ self.path = path
80
+
81
+ def __str__(self) -> str:
82
+ return self.path
83
+
84
+
85
+ class RecordingError(RuntimeError):
86
+ """The API refused the action (HTTP 4xx/5xx) or could not be reached."""
87
+
88
+
89
+ # ---------------- mapping ----------------
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ToolMapping:
94
+ """How one MCP tool becomes an action. Paths are dotted paths into the ``tools/call`` params object
95
+ (``arguments.order_id``, ``name``, ``_meta.progressToken``)."""
96
+
97
+ contract_id: str
98
+ subject: str
99
+ action_type: str | None = None
100
+ subject_kind: str | None = None
101
+ contract_version: int | None = None
102
+ params: dict[str, str] = field(default_factory=dict)
103
+ action_id: str | None = None
104
+ compensates: str | None = None
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Mapping:
109
+ tools: dict[str, ToolMapping]
110
+ agent_id: str | None = None
111
+ workflow_id: str | None = None
112
+
113
+ def lookup(self, tool_name: str) -> ToolMapping | None:
114
+ """The mapping for ``tool_name``: an exact key first, then a key carrying a server prefix
115
+ (``stripe.refund_payment`` matches ``refund_payment``). Unmapped tools return ``None``."""
116
+ exact = self.tools.get(tool_name)
117
+ if exact is not None:
118
+ return exact
119
+ for key, tool in self.tools.items():
120
+ _prefix, sep, bare = key.rpartition(".")
121
+ if sep and bare == tool_name:
122
+ return tool
123
+ return None
124
+
125
+
126
+ def _optional_str(value: Any, where: str) -> str | None:
127
+ if value is None:
128
+ return None
129
+ if not isinstance(value, str) or not value:
130
+ raise MappingError(f"{where} must be a non-empty string")
131
+ return value
132
+
133
+
134
+ def _required_str(obj: dict[str, Any], key: str, where: str) -> str:
135
+ if key not in obj:
136
+ raise MappingError(f"{where} is missing required key {key!r}")
137
+ value = obj[key]
138
+ if not isinstance(value, str) or not value:
139
+ raise MappingError(f"{where}.{key} must be a non-empty string")
140
+ return value
141
+
142
+
143
+ def _tool_mapping(name: str, spec: Any) -> ToolMapping:
144
+ where = f"tools.{name}"
145
+ if not isinstance(spec, dict):
146
+ raise MappingError(f"{where} must be an object")
147
+ params_spec = spec.get("params") or {}
148
+ if not isinstance(params_spec, dict):
149
+ raise MappingError(f"{where}.params must be an object of name -> path")
150
+ params: dict[str, str] = {}
151
+ for param_name, path in params_spec.items():
152
+ if not isinstance(path, str) or not path:
153
+ raise MappingError(f"{where}.params.{param_name} must be a non-empty path string")
154
+ params[param_name] = path
155
+ version = spec.get("contract_version")
156
+ if version is not None and (not isinstance(version, int) or isinstance(version, bool)):
157
+ raise MappingError(f"{where}.contract_version must be an integer")
158
+ strings = {key: _optional_str(spec.get(key), f"{where}.{key}") for key in _TOOL_STRING_FIELDS}
159
+ return ToolMapping(
160
+ contract_id=_required_str(spec, "contract_id", where),
161
+ subject=_required_str(spec, "subject", where),
162
+ contract_version=version,
163
+ params=params,
164
+ **strings,
165
+ )
166
+
167
+
168
+ def _read_mapping_file(path: Path) -> Any:
169
+ try:
170
+ text = path.read_text(encoding="utf-8")
171
+ except OSError as e:
172
+ raise MappingError(f"cannot read mapping file {path}: {e.strerror or e}") from e
173
+ if path.suffix.lower() in (".yaml", ".yml"):
174
+ try:
175
+ import yaml # type: ignore[import-untyped]
176
+ except ImportError as e:
177
+ raise MappingError("YAML mappings need PyYAML (pip install pyyaml); JSON works without it") from e
178
+ try:
179
+ return yaml.safe_load(text)
180
+ except yaml.YAMLError as e:
181
+ raise MappingError(f"mapping file {path} is not valid YAML: {e}") from e
182
+ try:
183
+ return json.loads(text)
184
+ except ValueError as e:
185
+ raise MappingError(f"mapping file {path} is not valid JSON: {e}") from e
186
+
187
+
188
+ def load_mapping(source: str | Path | dict[str, Any]) -> Mapping:
189
+ """Load and validate a mapping from a JSON file, a YAML file (when PyYAML is installed) or a dict.
190
+
191
+ Unknown keys are allowed. Every tool needs ``contract_id`` and ``subject``; ``params`` is an object of
192
+ parameter name to path; ``action_id`` is a template with ``{path}`` placeholders.
193
+ """
194
+ raw = source if isinstance(source, dict) else _read_mapping_file(Path(source))
195
+ if not isinstance(raw, dict):
196
+ raise MappingError("mapping must be an object with a 'tools' key")
197
+ tools_spec = raw.get("tools")
198
+ if not isinstance(tools_spec, dict) or not tools_spec:
199
+ raise MappingError("mapping needs a non-empty 'tools' object")
200
+ tools = {str(name): _tool_mapping(str(name), spec) for name, spec in tools_spec.items()}
201
+ return Mapping(
202
+ tools=tools,
203
+ agent_id=_optional_str(raw.get("agent_id"), "agent_id"),
204
+ workflow_id=_optional_str(raw.get("workflow_id"), "workflow_id"),
205
+ )
206
+
207
+
208
+ # ---------------- action bodies ----------------
209
+
210
+
211
+ def resolve_path(obj: Any, path: str) -> Any:
212
+ """Follow a dotted path through nested objects and lists. Raises ``UnresolvedPath`` when any step is
213
+ missing; a present ``null`` is returned as ``None``."""
214
+ current = obj
215
+ for part in path.split("."):
216
+ if isinstance(current, dict) and part in current:
217
+ current = current[part]
218
+ elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
219
+ current = current[int(part)]
220
+ else:
221
+ raise UnresolvedPath(path)
222
+ return current
223
+
224
+
225
+ def _render_template(template: str, call_params: dict[str, Any]) -> str:
226
+ def substitute(match: re.Match[str]) -> str:
227
+ value = resolve_path(call_params, match.group(1))
228
+ if value is None:
229
+ raise UnresolvedPath(match.group(1))
230
+ return str(value)
231
+
232
+ return _TEMPLATE_FIELD.sub(substitute, template)
233
+
234
+
235
+ def _now() -> str:
236
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
237
+
238
+
239
+ def build_action(
240
+ tool_name: str,
241
+ call_params: dict[str, Any],
242
+ tool: ToolMapping,
243
+ *,
244
+ request_id: Any,
245
+ agent_id: str | None = None,
246
+ workflow_id: str | None = None,
247
+ requested_at: str | None = None,
248
+ ) -> dict[str, Any]:
249
+ """The ``POST /v1/actions`` body for one ``tools/call``.
250
+
251
+ ``call_params`` is the ``params`` object of the JSON-RPC request (``name``, ``arguments``, ``_meta``).
252
+ ``params`` always carries ``mcp_tool`` and ``mcp_request_id``. The default ``action_id`` is
253
+ ``mcp:{tool}:{request id}``, so a retried request updates one record instead of creating two. Raises
254
+ ``UnresolvedPath`` when the subject or an ``action_id`` placeholder is absent from the call.
255
+ """
256
+ subject = resolve_path(call_params, tool.subject)
257
+ if subject is None or str(subject) == "":
258
+ raise UnresolvedPath(tool.subject)
259
+ params: dict[str, Any] = {}
260
+ for name, path in tool.params.items():
261
+ try:
262
+ params[name] = resolve_path(call_params, path)
263
+ except UnresolvedPath:
264
+ continue
265
+ params["mcp_tool"] = tool_name
266
+ params["mcp_request_id"] = str(request_id)
267
+ if tool.action_id is not None:
268
+ action_id = _render_template(tool.action_id, call_params)
269
+ else:
270
+ action_id = f"mcp:{tool_name}:{request_id}"
271
+ body: dict[str, Any] = {
272
+ "action_id": action_id,
273
+ "contract_id": tool.contract_id,
274
+ "subject_id": str(subject),
275
+ "requested_at": requested_at or _now(),
276
+ "params": params,
277
+ "channel": "mcp",
278
+ }
279
+ compensates: Any = None
280
+ if tool.compensates is not None:
281
+ try:
282
+ compensates = resolve_path(call_params, tool.compensates)
283
+ except UnresolvedPath:
284
+ compensates = None
285
+ for key, value in (
286
+ ("contract_version", tool.contract_version),
287
+ ("workflow_id", workflow_id),
288
+ ("agent_id", agent_id),
289
+ ("action_type", tool.action_type),
290
+ ("subject_kind", tool.subject_kind),
291
+ ("compensates", None if compensates is None else str(compensates)),
292
+ ):
293
+ if value is not None:
294
+ body[key] = value
295
+ return body
296
+
297
+
298
+ # ---------------- transport ----------------
299
+
300
+
301
+ def post_action(api_url: str, api_key: str, body: dict[str, Any], *, timeout: float = 5.0) -> dict[str, Any]:
302
+ """``POST {api_url}/v1/actions``. Returns the decoded response; raises ``RecordingError`` on HTTP 4xx/5xx
303
+ or when the API cannot be reached. The key is sent in ``x-api-key`` and never logged."""
304
+ req = urllib.request.Request(
305
+ api_url.rstrip("/") + "/v1/actions",
306
+ data=json.dumps(body).encode(),
307
+ method="POST",
308
+ headers={
309
+ "x-api-key": api_key,
310
+ "content-type": "application/json",
311
+ "accept": "application/json",
312
+ "user-agent": f"commitlayer-mcp-recorder/{__version__}",
313
+ },
314
+ )
315
+ try:
316
+ with urllib.request.urlopen(req, timeout=timeout) as response: # noqa: S310 - operator-configured URL
317
+ raw = response.read()
318
+ except urllib.error.HTTPError as e:
319
+ detail = e.read().decode(errors="replace")[:300]
320
+ raise RecordingError(f"HTTP {e.code} from {req.full_url}: {detail or e.reason}") from e
321
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
322
+ reason = getattr(e, "reason", None) or e
323
+ raise RecordingError(f"cannot reach {req.full_url}: {reason}") from e
324
+ try:
325
+ return json.loads(raw) if raw else {}
326
+ except ValueError:
327
+ return {}
328
+
329
+
330
+ # ---------------- recorder ----------------
331
+
332
+
333
+ @dataclass(frozen=True)
334
+ class Decision:
335
+ """What to do with one client message: forward it, or (fail-closed only) answer the client with
336
+ ``error`` instead. ``action_id`` is set when an action was recorded for the message."""
337
+
338
+ forward: bool = True
339
+ action_id: str | None = None
340
+ error: dict[str, Any] | None = None
341
+
342
+
343
+ def _id_key(request_id: Any) -> str:
344
+ # JSON-RPC ids may be numbers or strings and ``1`` is not ``"1"``; key on the JSON encoding.
345
+ return json.dumps(request_id, sort_keys=True)
346
+
347
+
348
+ class Recorder:
349
+ """Records mapped ``tools/call`` requests and matches downstream answers back to them.
350
+
351
+ ``before_forward`` is called with every parsed client message before it is sent downstream and
352
+ ``after_response`` with every parsed server message before it is sent back to the client. Both accept
353
+ anything ``json.loads`` can return and ignore what is not a JSON-RPC object. Without an API key the
354
+ recorder is a passthrough: nothing is posted. CLI flags for ``agent_id``/``workflow_id`` override the
355
+ mapping file.
356
+ """
357
+
358
+ def __init__(
359
+ self,
360
+ mapping: Mapping,
361
+ *,
362
+ api_key: str | None = None,
363
+ api_url: str | None = None,
364
+ agent_id: str | None = None,
365
+ workflow_id: str | None = None,
366
+ timeout: float = 5.0,
367
+ fail_open: bool = True,
368
+ warn_unmapped: bool = False,
369
+ transport: Transport | None = None,
370
+ log: Logger = log_to_stderr,
371
+ ):
372
+ self.mapping = mapping
373
+ self.api_key = api_key or ""
374
+ self.api_url = (api_url or DEFAULT_API_URL).rstrip("/")
375
+ self.agent_id = agent_id or mapping.agent_id
376
+ self.workflow_id = workflow_id or mapping.workflow_id
377
+ self.timeout = timeout
378
+ self.fail_open = fail_open
379
+ self.warn_unmapped = warn_unmapped
380
+ self._transport = transport or self._post
381
+ self._has_custom_transport = transport is not None
382
+ self._log = log
383
+ self._lock = threading.Lock()
384
+ self._pending: dict[str, str] = {}
385
+ self._warned_unmapped: set[str] = set()
386
+
387
+ @property
388
+ def enabled(self) -> bool:
389
+ """``False`` without an API key: every call is forwarded and nothing is recorded."""
390
+ return bool(self.api_key) or self._has_custom_transport
391
+
392
+ def _post(self, body: dict[str, Any]) -> dict[str, Any]:
393
+ return post_action(self.api_url, self.api_key, body, timeout=self.timeout)
394
+
395
+ def before_forward(self, message: Any) -> Decision:
396
+ """Record the message if it is a mapped ``tools/call``; say whether to forward it."""
397
+ if not isinstance(message, dict) or message.get("method") != "tools/call":
398
+ return Decision()
399
+ call_params = message.get("params")
400
+ if not isinstance(call_params, dict) or not isinstance(call_params.get("name"), str):
401
+ return Decision()
402
+ tool_name: str = call_params["name"]
403
+ tool = self.mapping.lookup(tool_name)
404
+ if tool is None:
405
+ if self.warn_unmapped and tool_name not in self._warned_unmapped:
406
+ self._warned_unmapped.add(tool_name)
407
+ self._log(f"unmapped tool {tool_name!r} forwarded without recording")
408
+ return Decision()
409
+ if "id" not in message:
410
+ self._log(f"tools/call for {tool_name!r} has no JSON-RPC id; forwarded without recording")
411
+ return Decision()
412
+ if not self.enabled:
413
+ return Decision()
414
+ try:
415
+ body = build_action(
416
+ tool_name,
417
+ call_params,
418
+ tool,
419
+ request_id=message["id"],
420
+ agent_id=self.agent_id,
421
+ workflow_id=self.workflow_id,
422
+ )
423
+ except UnresolvedPath as e:
424
+ self._log(f"tool {tool_name!r} forwarded without recording: path {e} not found in the call")
425
+ return Decision()
426
+ action_id = body["action_id"]
427
+ try:
428
+ self._transport(body)
429
+ except Exception as e: # noqa: BLE001 - any failure to record is handled by the fail-open policy
430
+ if self.fail_open:
431
+ self._log(f"action {action_id} not recorded ({e}); forwarding anyway")
432
+ return Decision()
433
+ self._log(f"action {action_id} not recorded ({e}); call refused because fail-open is off")
434
+ return Decision(
435
+ forward=False,
436
+ action_id=action_id,
437
+ error={
438
+ "jsonrpc": "2.0",
439
+ "id": message["id"],
440
+ "error": {
441
+ "code": JSONRPC_RECORDING_FAILED,
442
+ "message": f"CommitLayer recording failed: {e}",
443
+ },
444
+ },
445
+ )
446
+ with self._lock:
447
+ self._pending[_id_key(message["id"])] = action_id
448
+ self._log(f"recorded {action_id} for tool {tool_name!r}")
449
+ return Decision(action_id=action_id)
450
+
451
+ def after_response(self, message: Any) -> str | None:
452
+ """Match a downstream answer to a recorded request and log its ``isError`` flag. The result itself is
453
+ never sent to the API: a tool's own report of success is not evidence."""
454
+ if not isinstance(message, dict) or "id" not in message or "method" in message:
455
+ return None
456
+ with self._lock:
457
+ action_id = self._pending.pop(_id_key(message["id"]), None)
458
+ if action_id is None:
459
+ return None
460
+ result = message.get("result")
461
+ is_error = bool(result.get("isError")) if isinstance(result, dict) else "error" in message
462
+ self._log(f"recorded {action_id} → tool result isError={'true' if is_error else 'false'}")
463
+ return action_id
464
+
465
+ def pending(self) -> dict[str, str]:
466
+ """Recorded requests still waiting for a downstream answer: JSON-RPC id (encoded) → action id."""
467
+ with self._lock:
468
+ return dict(self._pending)
@@ -0,0 +1,201 @@
1
+ """``commitlayer-mcp-recorder``: the stdio proxy.
2
+
3
+ commitlayer-mcp-recorder --map mapping.json [--agent-id ID] [--workflow-id ID] [--api-url URL]
4
+ [--timeout 5] [--no-fail-open] [--warn-unmapped] -- <downstream command and args>
5
+
6
+ Client stdin is pumped line by line to the downstream server's stdin and the server's stdout back to the
7
+ client's stdout, in order and unchanged. The server's stderr is relayed to our stderr. The proxy exits when
8
+ the client closes stdin or the server exits, with the server's exit code.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ from typing import Any, BinaryIO
20
+
21
+ from . import (
22
+ DEFAULT_API_URL,
23
+ MappingError,
24
+ Recorder,
25
+ __version__,
26
+ load_mapping,
27
+ log_to_stderr,
28
+ )
29
+
30
+ GRACE_SECONDS = 5.0
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="commitlayer-mcp-recorder",
36
+ description=(
37
+ "Forward MCP stdio traffic to a downstream server and record mapped tools/call requests as "
38
+ "CommitLayer actions before they are forwarded. The downstream command follows '--'."
39
+ ),
40
+ epilog=(
41
+ "Environment: COMMITLAYER_API_KEY (required to record; without it the proxy is a passthrough), "
42
+ f"COMMITLAYER_API_URL (default {DEFAULT_API_URL}), COMMITLAYER_RECORDER_MAP (default for --map)."
43
+ ),
44
+ )
45
+ parser.add_argument(
46
+ "--map",
47
+ default=os.environ.get("COMMITLAYER_RECORDER_MAP"),
48
+ help="mapping file (JSON, or YAML when PyYAML is installed); default $COMMITLAYER_RECORDER_MAP",
49
+ )
50
+ parser.add_argument("--agent-id", help="overrides agent_id from the mapping file")
51
+ parser.add_argument("--workflow-id", help="overrides workflow_id from the mapping file")
52
+ parser.add_argument(
53
+ "--api-url",
54
+ default=os.environ.get("COMMITLAYER_API_URL") or DEFAULT_API_URL,
55
+ help=f"CommitLayer API base URL; default $COMMITLAYER_API_URL or {DEFAULT_API_URL}",
56
+ )
57
+ parser.add_argument("--timeout", type=float, default=5.0, help="seconds to wait for the API (default 5)")
58
+ parser.add_argument(
59
+ "--no-fail-open",
60
+ action="store_true",
61
+ help="when recording fails, answer the client with a JSON-RPC error instead of forwarding the call",
62
+ )
63
+ parser.add_argument(
64
+ "--warn-unmapped",
65
+ action="store_true",
66
+ help="log each tool name that is called but not in the mapping, once",
67
+ )
68
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
69
+ return parser
70
+
71
+
72
+ def _parse(line: bytes) -> Any:
73
+ try:
74
+ return json.loads(line)
75
+ except ValueError:
76
+ return None
77
+
78
+
79
+ def run_proxy(
80
+ command: list[str],
81
+ recorder: Recorder,
82
+ *,
83
+ stdin: BinaryIO | None = None,
84
+ stdout: BinaryIO | None = None,
85
+ log: Any = log_to_stderr,
86
+ ) -> int:
87
+ """Run ``command`` as the downstream server and pump traffic until the client or the server is done.
88
+ Returns the exit code to leave with (the server's)."""
89
+ client_in = stdin or sys.stdin.buffer
90
+ client_out = stdout or sys.stdout.buffer
91
+ try:
92
+ child = subprocess.Popen(
93
+ command,
94
+ stdin=subprocess.PIPE,
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.PIPE,
97
+ )
98
+ except OSError as e:
99
+ log(f"cannot start downstream server {command[0]!r}: {e.strerror or e}")
100
+ return 127
101
+ assert child.stdin is not None and child.stdout is not None and child.stderr is not None
102
+ child_in, child_out, child_err = child.stdin, child.stdout, child.stderr
103
+ out_lock = threading.Lock()
104
+
105
+ def emit(data: bytes) -> None:
106
+ with out_lock:
107
+ client_out.write(data)
108
+ client_out.flush()
109
+
110
+ def pump_client() -> None:
111
+ try:
112
+ for line in iter(client_in.readline, b""):
113
+ decision = recorder.before_forward(_parse(line))
114
+ if decision.forward:
115
+ child_in.write(line)
116
+ child_in.flush()
117
+ elif decision.error is not None:
118
+ emit(json.dumps(decision.error, separators=(",", ":")).encode() + b"\n")
119
+ except (OSError, ValueError):
120
+ pass # the server went away; the main thread collects its exit code
121
+ finally:
122
+ try:
123
+ child_in.close()
124
+ except OSError:
125
+ pass
126
+ try:
127
+ child.wait(timeout=GRACE_SECONDS)
128
+ except subprocess.TimeoutExpired:
129
+ log("downstream server did not exit after the client closed stdin; terminating it")
130
+ child.terminate()
131
+
132
+ def pump_server() -> None:
133
+ try:
134
+ for line in iter(child_out.readline, b""):
135
+ recorder.after_response(_parse(line))
136
+ emit(line)
137
+ except OSError:
138
+ pass # the client went away
139
+
140
+ def pump_stderr() -> None:
141
+ try:
142
+ for chunk in iter(lambda: child_err.read1(65536), b""):
143
+ sys.stderr.buffer.write(chunk)
144
+ sys.stderr.buffer.flush()
145
+ except OSError:
146
+ pass
147
+
148
+ threads = [
149
+ threading.Thread(target=pump_client, name="client->server", daemon=True),
150
+ threading.Thread(target=pump_server, name="server->client", daemon=True),
151
+ threading.Thread(target=pump_stderr, name="server-stderr", daemon=True),
152
+ ]
153
+ for t in threads:
154
+ t.start()
155
+ try:
156
+ child.wait()
157
+ except KeyboardInterrupt:
158
+ child.terminate()
159
+ child.wait()
160
+ for t in threads[1:]:
161
+ t.join(timeout=GRACE_SECONDS)
162
+ code = child.returncode
163
+ return code if code >= 0 else 128 - code
164
+
165
+
166
+ def main(argv: list[str] | None = None) -> int:
167
+ args_in = list(sys.argv[1:] if argv is None else argv)
168
+ if "--" in args_in:
169
+ split = args_in.index("--")
170
+ options, command = args_in[:split], args_in[split + 1 :]
171
+ else:
172
+ options, command = args_in, []
173
+ parser = build_parser()
174
+ args = parser.parse_args(options)
175
+ if not command:
176
+ parser.error("the downstream server command is required after '--'")
177
+ if not args.map:
178
+ parser.error("--map is required (or set COMMITLAYER_RECORDER_MAP)")
179
+ try:
180
+ mapping = load_mapping(args.map)
181
+ except MappingError as e:
182
+ log_to_stderr(f"invalid mapping {args.map}: {e}")
183
+ return 2
184
+ api_key = os.environ.get("COMMITLAYER_API_KEY") or ""
185
+ if not api_key:
186
+ log_to_stderr("COMMITLAYER_API_KEY is not set; running as a passthrough, nothing will be recorded")
187
+ recorder = Recorder(
188
+ mapping,
189
+ api_key=api_key,
190
+ api_url=args.api_url,
191
+ agent_id=args.agent_id,
192
+ workflow_id=args.workflow_id,
193
+ timeout=args.timeout,
194
+ fail_open=not args.no_fail_open,
195
+ warn_unmapped=args.warn_unmapped,
196
+ )
197
+ return run_proxy(command, recorder)
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main())
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: commitlayer-mcp-recorder
3
+ Version: 0.1.0
4
+ Summary: CommitLayer MCP Recorder: a stdio proxy that records an agent's MCP tool calls as CommitLayer actions
5
+ Author-email: "Asterfold Labs, Inc." <hello@commitlayer.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://commitlayer.ai
8
+ Project-URL: Documentation, https://commitlayer.ai/docs
9
+ Project-URL: Changelog, https://commitlayer.ai/changelog
10
+ Project-URL: Source, https://github.com/kobikwelu/stateprf-svcs/tree/main/recorder/python
11
+ Keywords: ai-agents,verification,audit,mcp,observability
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: yaml
24
+ Requires-Dist: pyyaml>=6.0; extra == "yaml"
25
+ Dynamic: license-file
26
+
27
+ # CommitLayer MCP Recorder (Python)
28
+
29
+ A small stdio proxy that sits between an MCP client (Claude Desktop, Claude Code, Cursor, an agent framework)
30
+ and any downstream MCP server (a Stripe or Zendesk MCP server, your own). It forwards every JSON-RPC message
31
+ unchanged in both directions, and when the client calls a mapped tool it records the intent as a CommitLayer
32
+ action **before** forwarding the call. Standard library only; Python 3.10+.
33
+
34
+ > The agent may declare its intent. It may read the verdict. It must never control the independent facts, the governing contract, or the final evidence.
35
+
36
+ The recorder only does the first of those three. It never blocks, reorders or alters the agent's traffic, and
37
+ the downstream tool's result is never sent to CommitLayer: a tool answering "refund issued" is a claim, not
38
+ evidence. Verification reads the systems of record.
39
+
40
+ ```bash
41
+ pip install ./recorder/python # from this repository; `pip install pyyaml` for .yaml mappings
42
+ export COMMITLAYER_API_KEY=spk_... # a workspace API key; without it the proxy is a passthrough
43
+ commitlayer-mcp-recorder --map mapping.json -- npx -y @stripe/mcp --tools=all
44
+ ```
45
+
46
+ ## Wiring it into a client
47
+
48
+ Wherever the client is told how to start the server, put the recorder in front and move the real command
49
+ after `--`.
50
+
51
+ Claude Desktop (`claude_desktop_config.json`):
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "stripe": {
57
+ "command": "commitlayer-mcp-recorder",
58
+ "args": ["--map", "/etc/commitlayer/stripe.json", "--agent-id", "support-bot", "--", "npx", "-y", "@stripe/mcp", "--tools=all"],
59
+ "env": {"COMMITLAYER_API_KEY": "spk_..."}
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ Claude Code:
66
+
67
+ ```bash
68
+ claude mcp add stripe -e COMMITLAYER_API_KEY=spk_... -- commitlayer-mcp-recorder --map stripe.json -- npx -y @stripe/mcp --tools=all
69
+ ```
70
+
71
+ Cursor and other clients use the same `command` / `args` / `env` shape. The downstream server does not know
72
+ the recorder is there; it sees the same bytes it would have seen.
73
+
74
+ ## CLI
75
+
76
+ ```
77
+ commitlayer-mcp-recorder --map mapping.json [--agent-id ID] [--workflow-id ID] [--api-url URL]
78
+ [--timeout 5] [--no-fail-open] [--warn-unmapped] -- <downstream command and args>
79
+ ```
80
+
81
+ | Flag | Meaning |
82
+ | --- | --- |
83
+ | `--map` | Mapping file, JSON or YAML (YAML needs PyYAML). Default `$COMMITLAYER_RECORDER_MAP`. |
84
+ | `--agent-id`, `--workflow-id` | Override `agent_id` / `workflow_id` from the mapping file. |
85
+ | `--api-url` | Default `$COMMITLAYER_API_URL`, then `https://api.commitlayer.ai`. |
86
+ | `--timeout` | Seconds to wait for the API per call (default 5). |
87
+ | `--no-fail-open` | When recording fails, refuse the call instead of forwarding it (see below). |
88
+ | `--warn-unmapped` | Log each tool that is called but not mapped, once per tool name. |
89
+
90
+ Environment: `COMMITLAYER_API_KEY` (required to record; without it the recorder warns once at startup and
91
+ runs as a pure passthrough), `COMMITLAYER_API_URL`, `COMMITLAYER_RECORDER_MAP`. The key is never logged. All
92
+ diagnostics go to stderr, one line each, prefixed `commitlayer-recorder:`. The downstream server's own stderr
93
+ is relayed as is.
94
+
95
+ The proxy exits when the client closes stdin or the downstream server exits, with the server's exit code.
96
+
97
+ ## Mapping file
98
+
99
+ ```json
100
+ {
101
+ "agent_id": "support-bot",
102
+ "workflow_id": "refunds",
103
+ "tools": {
104
+ "refund_payment": {
105
+ "contract_id": "refund_workflow",
106
+ "action_type": "refund_order",
107
+ "subject": "arguments.order_id",
108
+ "subject_kind": "order",
109
+ "params": {"amount": "arguments.amount", "currency": "arguments.currency"},
110
+ "action_id": "refund:{arguments.order_id}",
111
+ "compensates": "arguments.original_action_id"
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ - Keys under `tools` are MCP tool names. An exact match wins; a key with a server prefix
118
+ (`stripe.refund_payment`) also matches the bare name (`refund_payment`).
119
+ - `contract_id` and `subject` are required per tool. Unknown keys are allowed and ignored.
120
+ - `subject`, each `params` value and `compensates` are dotted paths into the `tools/call` params object:
121
+ `arguments.order_id`, `name`, `_meta.progressToken`, `arguments.items.0`.
122
+ - `action_id` is a template with `{path}` placeholders. Without it the id is `mcp:{tool}:{request id}`, so a
123
+ retried request updates one record instead of creating two.
124
+ - `contract_version` (integer), `action_type` and `subject_kind` are passed through.
125
+ - A `params` path that is absent from the call is skipped; an absent `compensates` path is omitted. An absent
126
+ `subject` (or `action_id` placeholder) means the call is forwarded **but not recorded**, with a warning.
127
+
128
+ The mapping is validated on load; an invalid file stops the proxy with exit code 2 and a message naming the
129
+ tool and key.
130
+
131
+ ### Example: the Coinbase MCP server
132
+
133
+ [`examples/coinbase.json`](examples/coinbase.json) maps `orders_create` (the order tool of the Coinbase MCP
134
+ server behind Coinbase for Agents) to the `coinbase_agent_trade` library contract: the subject is the
135
+ product id the agent passes, `amount` is its quote size, `side` and `client_order_id` ride along, and the
136
+ action id is derived from the client order id so a retried call updates one record. The paths follow the
137
+ create-order request; run the recorder once with `--warn-unmapped` against the server's own `tools/list`
138
+ and adjust them if the argument names differ. The Coinbase server is remote (HTTP), and the recorder is a
139
+ stdio proxy: put it in front of whatever stdio bridge the client uses to reach the remote server, so the
140
+ bridge is the "downstream command". Verification then reads the order and its fills from Coinbase through
141
+ the Coinbase connector; the tool's own answer is never evidence.
142
+
143
+ ## What is recorded
144
+
145
+ For each client → server `tools/call` whose `params.name` is mapped, the recorder posts one action to
146
+ `POST {api_url}/v1/actions` (`x-api-key`, `content-type: application/json`) and then forwards the original
147
+ line unchanged:
148
+
149
+ ```json
150
+ {
151
+ "action_id": "refund:order_10231",
152
+ "contract_id": "refund_workflow",
153
+ "subject_id": "order_10231",
154
+ "subject_kind": "order",
155
+ "action_type": "refund_order",
156
+ "agent_id": "support-bot",
157
+ "workflow_id": "refunds",
158
+ "requested_at": "2026-10-15T12:00:00Z",
159
+ "params": {"amount": 180.0, "currency": "USD", "mcp_tool": "refund_payment", "mcp_request_id": "7"},
160
+ "channel": "mcp"
161
+ }
162
+ ```
163
+
164
+ `params` always carries `mcp_tool` and `mcp_request_id`. `requested_at` is taken when the call is seen, so
165
+ it precedes every effect. Unmapped tools, notifications, `initialize`, `tools/list` and everything else pass
166
+ straight through.
167
+
168
+ When the downstream answer for a recorded request comes back, the recorder logs one line:
169
+
170
+ ```
171
+ commitlayer-recorder: recorded refund:order_10231 → tool result isError=false
172
+ ```
173
+
174
+ That is all it does with the result. **The tool's own "success" is never evidence.** It is not sent to the
175
+ API and it does not change the verdict; CommitLayer settles the action from the facts the systems of record
176
+ report, under the governing contract. Read the verdict with the SDK or `GET /v1/actions/{action_id}`.
177
+
178
+ ## Fail-open, and the alternative
179
+
180
+ By default recording is fail-open: if the API answers 4xx/5xx, times out or cannot be reached, a warning is
181
+ written to stderr and the call is forwarded anyway. A verifier that is down must not stop the business
182
+ action; the gap shows up as an action that was never recorded rather than one that never happened.
183
+
184
+ With `--no-fail-open` a call whose recording fails is **not** forwarded. The client receives a JSON-RPC
185
+ error for that request id (`code -32000`, message `CommitLayer recording failed: …`) and the downstream
186
+ server never sees it. Other traffic is unaffected.
187
+
188
+ ## Library use
189
+
190
+ ```python
191
+ from commitlayer_mcp_recorder import Recorder, build_action, load_mapping
192
+
193
+ mapping = load_mapping("mapping.json")
194
+ recorder = Recorder(mapping, api_key="spk_...", agent_id="support-bot")
195
+ decision = recorder.before_forward(message) # a parsed client message; .forward, .action_id, .error
196
+ recorder.after_response(message) # a parsed server message; logs the isError flag
197
+ ```
198
+
199
+ `build_action(tool_name, call_params, tool_mapping, request_id=...)` returns the body shown above.
200
+
201
+ ## Limits
202
+
203
+ - Stdio transport only, newline-delimited JSON-RPC. Lines that are not JSON are forwarded untouched.
204
+ - JSON-RPC batch arrays are forwarded untouched and not inspected.
205
+ - A `tools/call` without a JSON-RPC id is forwarded without recording (there is nothing to match the answer
206
+ to).
207
+
208
+ ## Tests
209
+
210
+ ```bash
211
+ python -m pytest recorder/python/tests -q
212
+ ```
@@ -0,0 +1,8 @@
1
+ commitlayer_mcp_recorder/__init__.py,sha256=KIslkmfQvpLzX-1TY9Nv69OP7ba69s3PeqMQv-poS8g,18122
2
+ commitlayer_mcp_recorder/__main__.py,sha256=BK4dJ_UW4-OBgNWo51r4dsBHpdtnqL-ZKrW1_Nam57Y,7031
3
+ commitlayer_mcp_recorder-0.1.0.dist-info/licenses/LICENSE,sha256=0vhmIhIFNhgH1c9Yg3fPGr0tYr606CAcOisVbgKTbb8,1077
4
+ commitlayer_mcp_recorder-0.1.0.dist-info/METADATA,sha256=-DtQGgaj791URu2hKatYb3AGyXfzsAjGaCbcLm_VHeI,9637
5
+ commitlayer_mcp_recorder-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ commitlayer_mcp_recorder-0.1.0.dist-info/entry_points.txt,sha256=MmdYoTsIv7Ngu998m32LqEcErrk-SdC2PDr0GnrrqxA,84
7
+ commitlayer_mcp_recorder-0.1.0.dist-info/top_level.txt,sha256=hKCwcVoNT2In6eRdGe5cRFc8hj6HeW-SwF5VWXoHW6Q,25
8
+ commitlayer_mcp_recorder-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
+ commitlayer-mcp-recorder = commitlayer_mcp_recorder.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asterfold Labs, Inc.
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
+ commitlayer_mcp_recorder