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.
- commitlayer_mcp_recorder/__init__.py +468 -0
- commitlayer_mcp_recorder/__main__.py +201 -0
- commitlayer_mcp_recorder-0.1.0.dist-info/METADATA +212 -0
- commitlayer_mcp_recorder-0.1.0.dist-info/RECORD +8 -0
- commitlayer_mcp_recorder-0.1.0.dist-info/WHEEL +5 -0
- commitlayer_mcp_recorder-0.1.0.dist-info/entry_points.txt +2 -0
- commitlayer_mcp_recorder-0.1.0.dist-info/licenses/LICENSE +21 -0
- commitlayer_mcp_recorder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|