arcaeon-adapter 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ *.log.jsonl
7
+ .venv/
8
+ mcp-publisher.exe
@@ -0,0 +1,105 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-08-19
4
+
5
+ First version. An MCP stdio proxy that records every tool call to a tamper-evident
6
+ ledger without the agent's cooperation.
7
+
8
+ ### Why this exists
9
+
10
+ `arcaeon_ledger.Ledger.append()` records whatever a caller chooses to pass it.
11
+ That is a diary: an agent that skips the append leaves no trace, and one that
12
+ curates its appends leaves a flattering record. Any regime asking for *automatic*
13
+ recording (EU AI Act Art. 12(1) is the one on our desk) cannot be met by a library
14
+ the logged party calls voluntarily. MCP stdio is the seam where "no cooperation
15
+ required" is literally true — the client and server talk newline-delimited
16
+ JSON-RPC over a pipe, and a process sitting in that pipe sees everything at the
17
+ protocol level, in a process the agent does not own.
18
+
19
+ Design memo: `projects/online_business/ADAPTER_LAYER_DESIGN_2026-08-18.md`
20
+ (seam ranking, schema, pricing sketch, and the frozen honest-limit copy).
21
+
22
+ ### Added
23
+
24
+ - **`arcaeon_adapter/proxy.py`** — the proxy.
25
+ `python -m arcaeon_adapter --ledger PATH -- <server command...>`. Spawns the
26
+ wrapped server, relays stdin→child and child→stdout byte-for-byte, logs the
27
+ seam. Child's stderr is inherited (not piped); child's exit code is propagated.
28
+ - **`arcaeon_adapter/observer.py`** — non-destructive stream observation.
29
+ `FrameSplitter` (chunk reassembly, unterminated final frame, bounded buffer with
30
+ resync) and `SeamObserver` (request/response pairing by JSON-RPC `id`, row
31
+ emission, session brackets).
32
+ - **`arcaeon_adapter/_ledger.py`** — ledger binding, with a byte-compatible
33
+ fallback writer + verifier for machines without `arcaeon-ledger` installed.
34
+ - **`arcaeon_adapter/selftest.py`** — mutation harness. Five cases, each observed
35
+ GREEN then forced RED on its own defect, with a no-op guard between.
36
+ - **`arcaeon_adapter/_echo_server.py`** — deterministic synthetic MCP server, so
37
+ fidelity can be measured against an unproxied control run.
38
+ - **Row schema:** `tool_call`, `session_begin`, `session_end`, `mcp_initialize`,
39
+ `tools_list`. Every row carries `seam="mcp-stdio"` + `seam_impl`, `session`,
40
+ `seq`, `server`.
41
+ - **Flags:** `--ledger`, `--server`, `--session`, `--raw`, `--max-frame`.
42
+ - 65 pytest tests.
43
+
44
+ ### Decisions, and what they cost
45
+
46
+ - **Digest-only by default; `--raw` opt-in.** Rows prove *which* bytes crossed
47
+ the seam without warehousing them. Cost: you cannot reconstruct a payload from
48
+ a row after the fact. That is the trade we want — an audit log that silently
49
+ accumulates everyone's data is a liability, and person-free rows are far easier
50
+ to retain for years.
51
+ - **Session is process-scoped, not `initialize`-scoped.** One proxy process = one
52
+ session, and `session_begin` fires at start rather than at the MCP handshake. A
53
+ begin row that waits for `initialize` does not exist when a client connects and
54
+ dies, and then there is nothing for `session_end` to pair with. What the
55
+ handshake tells us arrives separately as `mcp_initialize`.
56
+ - **`seam` is `"mcp-stdio"`.** The design memo sketched `"mcp-proxy/0.1"`, folding
57
+ the tier and the version into one string. Split: `seam` names the *provenance
58
+ tier* and must stay stable for a downstream verifier to switch on, while
59
+ `seam_impl` carries the build. A tier identifier that changes every release is
60
+ not a tier identifier.
61
+ - **An unanswered call is still rowed** (`status="unanswered"`, at shutdown).
62
+ Without it, killing the server mid-call erases the fact that the call crossed
63
+ the seam.
64
+ - **Oversized frames are counted and reported**, not silently skipped. A gap in
65
+ the record has to be visible in the record.
66
+ - **`arcaeon-ledger` is not a hard dependency.** This gets wrapped around
67
+ somebody else's working server by a config edit; a missing package must never be
68
+ why their server fails to start. The fallback is byte-compatible and labelled in
69
+ `ledger_backend`.
70
+ - **`.proxy` is imported lazily from `__init__`.** Not tidiness: importing it
71
+ eagerly made runpy print a `RuntimeWarning` to stderr on every launch, and the
72
+ proxy inherits the wrapped server's stderr. A logging sidecar that dirties the
73
+ logs is a bad joke. Guarded by a test.
74
+
75
+ ### Found while building
76
+
77
+ - The mutation harness's no-op guard fired twice, correctly, on mutations that
78
+ proved nothing:
79
+ 1. The `reserialize` fidelity fault originally did `json.dumps(json.loads(f))`,
80
+ which reproduced the echo server's own output byte-for-byte — a no-op. It now
81
+ re-emits sorted + compact, which actually differs.
82
+ 2. The `one_row_per_call` mutation originally delivered every frame twice, and
83
+ the row count did **not** change: popping the pending map makes the observer
84
+ idempotent against duplicate delivery. Good property, useless mutation. The
85
+ robustness is now its own test (`test_duplicate_response_delivery_does_not_
86
+ double_row`) and the mutation is a log-as-you-see observer that really does
87
+ break pairing.
88
+
89
+ Both are the harness doing its job — a check that stays green on its own defect
90
+ is decoration.
91
+
92
+ ### Deliberately left for v1
93
+
94
+ - `--authority principal=...` — stamp `arcaeon_ledger.authority()` on every row.
95
+ - `--bind-inputs` — auto `bind_artefact` on input payloads.
96
+ - `--auto-pin N` — publish a head pin every N rows to a witness (this is the
97
+ metered surface; the library itself stays free).
98
+ - Harness-hook recipe (`examples/claude_code_hooks/`) — catches built-ins the
99
+ proxy is structurally blind to, at the cost of being per-harness config and
100
+ cooperative-grade rather than infrastructure-grade.
101
+ - HTTP/gateway seam — the widest view of agent *intent*, and the most
102
+ person-full; a hosted-tier feature, not a v0 move.
103
+ - Streamable-HTTP MCP transport. v0 is stdio only, which is the transport where
104
+ a proxy is a config edit rather than a deployment.
105
+ - A `verify`-side reader that reports seam coverage across many session logs.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arcaeon
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,240 @@
1
+ Metadata-Version: 2.5
2
+ Name: arcaeon-adapter
3
+ Version: 0.1.0
4
+ Summary: MCP stdio proxy that records every tool call to a tamper-evident ledger, without the agent's cooperation.
5
+ Project-URL: Homepage, https://arcaeon.io
6
+ Project-URL: Changelog, https://github.com/dan8433-user/ledger/blob/main/adapter/CHANGELOG.md
7
+ Project-URL: Source, https://github.com/dan8433-user/ledger/tree/main/adapter
8
+ Project-URL: Issues, https://github.com/dan8433-user/ledger/issues
9
+ Author: Arcaeon
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,ai,audit,compliance,mcp,provenance,proxy,tamper-evident
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: System :: Logging
18
+ Requires-Python: >=3.9
19
+ Provides-Extra: dev
20
+ Requires-Dist: arcaeon-ledger>=0.5.7; extra == 'dev'
21
+ Requires-Dist: pytest; extra == 'dev'
22
+ Provides-Extra: ledger
23
+ Requires-Dist: arcaeon-ledger>=0.5.7; extra == 'ledger'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # arcaeon-adapter
27
+
28
+ **`arcaeon-ledger` proves a record wasn't altered. It can't prove the record is _complete_ — because the agent decides what to write. This moves the pen.**
29
+
30
+ `arcaeon-adapter` is a stdio proxy that sits between an MCP client and an MCP
31
+ server. It forwards JSON-RPC byte-for-byte, and on every `tools/call` it writes
32
+ one hash-chained row to its own ledger: which tool, digests of the arguments and
33
+ the result, ok or error, how long it took.
34
+
35
+ It runs in its own OS process. The agent is not consulted, cannot skip it, and
36
+ cannot see it.
37
+
38
+ ```
39
+ pip install arcaeon-adapter arcaeon-ledger
40
+ ```
41
+
42
+ Wrap any MCP server's command — one line of config, zero code:
43
+
44
+ ```json
45
+ { "mcpServers": {
46
+ "ledger": {
47
+ "command": "python",
48
+ "args": ["-m", "arcaeon_adapter", "--ledger", "seam.log.jsonl", "--",
49
+ "python", "-m", "arcaeon_ledger.mcp_server", "--log", "agent.log.jsonl"]
50
+ }
51
+ } }
52
+ ```
53
+
54
+ Or straight from a shell:
55
+
56
+ ```
57
+ python -m arcaeon_adapter --ledger seam.log.jsonl -- <your mcp server command...>
58
+ ```
59
+
60
+ ## What lands in the log
61
+
62
+ Real rows, from wrapping `arcaeon-ledger`'s own MCP server (the ledger logging
63
+ itself through the ledger), trimmed for width:
64
+
65
+ ```json
66
+ {"evt":"session_begin","seam":"mcp-stdio","seam_impl":"arcaeon-adapter/0.1.0",
67
+ "session":"25eda195-70e8-45cb-9035-be9f61cfc759","seq":1,
68
+ "server":"arcaeon_ledger.mcp_server","ledger_backend":"arcaeon-ledger/0.5.7",
69
+ "command":["python","-m","arcaeon_ledger.mcp_server","--log","agent.log.jsonl"],
70
+ "command_digest":"sha256:json-c14n:v1:ec06bd01…","raw_payloads":false,
71
+ "ts":"2026-08-19T14:18:08Z","chain":"0081ec1fe5d98395192f595e553b4133"}
72
+
73
+ {"evt":"tools_list","seq":3,"tools":["ledger_append","ledger_verify"],"tool_count":2,
74
+ "tools_digest":"sha256:json-c14n:v1:b85c9fce…","status":"ok", …}
75
+
76
+ {"evt":"tool_call","seq":4,"tool":"ledger_append","rpc_id":"3",
77
+ "args_digest":"sha256:json-c14n:v1:cfb2bb50…",
78
+ "result_digest":"sha256:json-c14n:v1:ba037f70…",
79
+ "status":"ok","ms":144, …}
80
+
81
+ {"evt":"session_end","seq":6,"reason":"child_exit","exit_code":0,
82
+ "rows_before_end":5,"calls":2, …}
83
+ ```
84
+
85
+ In that same run, the wrapped server's *own* log — the diary it keeps when the
86
+ agent remembers to call `ledger_append` — had **one** row. The seam log had six.
87
+ That difference is the entire product.
88
+
89
+ | field | why it's there |
90
+ |---|---|
91
+ | `evt` | `tool_call`, `session_begin`, `session_end`, `mcp_initialize`, `tools_list` |
92
+ | `seam` | always `"mcp-stdio"` — the **provenance tier**. A separate process saw this at the protocol level. A downstream verifier reads this field to tell infrastructure-grade capture from a cooperative in-process callback the agent could simply not call. |
93
+ | `session` / `seq` | one proxy process = one session; `seq` is a dense total order within it. `session_begin` + `session_end` bracket the run so a reviewer can pair a session's start and end. |
94
+ | `args_digest` / `result_digest` | `sha256:json-c14n:v1:<hex>` — self-describing, so a stranger holding only the row can reproduce the computation. |
95
+ | `status` / `ms` | `ok` / `error` / `unanswered`, and wall-clock duration. `error` covers **both** a JSON-RPC `error` and a `result` carrying `isError: true`. |
96
+
97
+ **Digests, not payloads, by default.** The row proves *which* bytes crossed the
98
+ seam without keeping them. An audit log that quietly becomes a copy of every
99
+ prompt and every result is a liability, not an asset — and a person-free core is
100
+ much easier to retain for years. `--raw` embeds the payloads for deployers who
101
+ own that risk; the digests stay either way.
102
+
103
+ **A call that never gets an answer still gets a row** (`status: "unanswered"`).
104
+ Otherwise "kill the server mid-call" would be a way to make an action leave no
105
+ trace at the seam, which is the exact hole this exists to close.
106
+
107
+ ## Verify it
108
+
109
+ The seam log is an ordinary `arcaeon-ledger` file:
110
+
111
+ ```
112
+ python -m arcaeon_ledger.cli verify seam.log.jsonl
113
+ ```
114
+
115
+ Edit a row, delete one, reorder them — every later link breaks and `verify` names
116
+ the exact line.
117
+
118
+ ## The honest limit
119
+
120
+ > **An adapter on one seam logs that seam completely — and nothing else.** An
121
+ > agent can still act around it: a direct HTTP call, an un-wrapped MCP server, a
122
+ > shell command never crosses this proxy and never hits this ledger. The
123
+ > second-set-of-books problem does not go away; no logging layer can force total
124
+ > honesty. What the adapter guarantees is narrower and real: *everything that
125
+ > crossed the instrumented seam is in the record, automatically, and the record
126
+ > proves itself.* Honesty is forced at the seams, not everywhere — not a lock, a
127
+ > neighborhood.
128
+
129
+ Specifically **blind** to:
130
+
131
+ - your harness's built-in tools (Bash, file edits, web fetch) — they never touch MCP
132
+ - any MCP server you did not wrap
133
+ - HTTP the agent makes natively
134
+ - the model's reasoning, which is not an action and leaves no wire trace
135
+ - frames larger than `--max-frame` (relayed fine; counted and reported in
136
+ `session_end` as `oversize_frames_unlogged`, never a silent gap)
137
+
138
+ And on compliance, plainly: **this does not make anyone compliant with
139
+ anything.** Regulations like the EU AI Act's Art. 12 place duties on a *provider*
140
+ or *deployer*, and duties land on people, not libraries. What this is: a
141
+ mechanism that makes recording at one seam automatic rather than voluntary,
142
+ which is a thing you would otherwise have to build. Retention policy, log
143
+ semantics, risk classification, and every other obligation remain yours. Anyone
144
+ selling you a package that "makes you compliant" is selling you a story.
145
+
146
+ Whoever controls the config can also remove the wrapper. That is the same class
147
+ of limit every logging layer has, and it is stated here rather than in a footnote.
148
+
149
+ ## Fidelity is the P0 property
150
+
151
+ A proxy that corrupts, reorders, or delays a customer's JSON-RPC is worse than no
152
+ proxy at all. So the design puts fidelity ahead of observation, structurally:
153
+
154
+ - **Bytes are forwarded first, observed second, from a copy.** A parsing defect
155
+ here cannot alter or withhold traffic; the worst it can do is produce a wrong
156
+ row.
157
+ - **Raw binary end to end.** No text mode anywhere — on Windows that would rewrite
158
+ `\n` as `\r\n` and silently alter every frame.
159
+ - **No reframing, ever.** We never parse a frame and re-emit it. The classic proxy
160
+ bug is `json.dumps(json.loads(frame))`: semantically identical, byte-different.
161
+ - **One thread per direction**, so ordering within a direction is the OS's.
162
+ - **The child's stderr is inherited, not piped.** Its diagnostics land exactly
163
+ where they did unwrapped, and the proxy itself prints nothing on an honest run.
164
+ - **Malformed frames are relayed untouched and not logged.** A server that prints
165
+ a stray line to stdout keeps working; we don't guess, because a guess in an
166
+ audit record is fiction.
167
+ - **The child's exit code is the proxy's exit code.**
168
+
169
+ This is tested by running a scripted client against a synthetic MCP server
170
+ directly, then through the proxy, and requiring the two stdout streams to be
171
+ identical *bytes* — including a 4 MB frame spanning ~64 pipe reads, unicode,
172
+ embedded CRLF, malformed lines, a notification with no id, and a final frame with
173
+ no trailing newline.
174
+
175
+ ## Prove the checks can fail
176
+
177
+ ```
178
+ python -m arcaeon_adapter.selftest
179
+ ```
180
+
181
+ An instrument that has never failed proves nothing. Every case runs GREEN on a
182
+ clean fixture, then **mutates** and requires the same check to go RED — with a
183
+ no-op guard in between, because a mutation that changes nothing proves nothing
184
+ either.
185
+
186
+ ```
187
+ PASS passthrough_fidelity GREEN: 2001202 bytes identical byte-for-byte through the proxy
188
+ PASS passthrough_fidelity RED on reserialize: corruption detected (length 2001202 vs 2001097)
189
+ PASS passthrough_fidelity RED on drop_byte: corruption detected (length 2001202 vs 2001168)
190
+ PASS one_row_per_call GREEN: 5 tools/call frames -> 5 tool_call rows, 9 rows total, seq 1..9
191
+ PASS one_row_per_call RED on log_on_request: count check caught 10 rows for 5 calls
192
+ PASS unanswered_call_logged GREEN: unanswered `quiet` call rowed with args_digest bound
193
+ PASS unanswered_call_logged RED on no_flush: dropping the shutdown flush loses the row
194
+ PASS tamper_detected GREEN: seam log verifies (ok=True, rows=9)
195
+ PASS tamper_detected RED on edited args_digest: first_break='line 4: chain mismatch'
196
+ PASS digest_recipe_frozen GREEN: 3 frozen json-c14n:v1 vectors reproduce exactly
197
+ PASS digest_recipe_frozen RED on unsorted_keys: drifted canonicalizer produces a different digest
198
+
199
+ ALL CHECKS PASSED — and every one was observed failing on its own defect.
200
+ ```
201
+
202
+ The fidelity mutations are injected into the **real relay**, not a mock, so what's
203
+ proven is that the comparison would catch a regression in the shipping code path.
204
+
205
+ ## Options
206
+
207
+ ```
208
+ --ledger PATH seam ledger (required). Keep it SEPARATE from any ledger the
209
+ wrapped server writes: one file, one writer, clean provenance.
210
+ --server NAME label for the wrapped server in every row (default: derived
211
+ from the command — `-m pkg.mod` reads as "pkg.mod", not "python")
212
+ --session ID session id (default: a fresh uuid4 per proxy process)
213
+ --raw embed raw argument and result payloads. Off by default.
214
+ --max-frame BYTES frames larger than this are relayed but not logged (default 64 MiB)
215
+ ```
216
+
217
+ ## Install and dependencies
218
+
219
+ Stdlib only at runtime. `arcaeon-ledger` is the intended writer and is what makes
220
+ a row provable — but it is **not** a hard dependency, on purpose: this gets
221
+ wrapped around somebody else's working MCP server by editing one line of config,
222
+ and a missing package must never be why their server fails to start. Without it,
223
+ rows are written by a byte-compatible fallback and `session_begin` says so in
224
+ `ledger_backend`, so a reviewer never has to guess which writer produced a chain.
225
+ (That byte-compatibility is asserted in the test suite, not just claimed here.)
226
+
227
+ **No network calls at runtime.** Ever.
228
+
229
+ ```
230
+ pytest -q # 65 tests
231
+ python -m arcaeon_adapter.selftest # the mutation harness
232
+ ```
233
+
234
+ ## Status
235
+
236
+ v0. Works, tested, dogfooded on our own MCP server. Deliberately not yet built:
237
+ `--authority` stamping, `--bind-inputs` artefact binding, `--auto-pin` witness
238
+ publication, and harness-hook / HTTP-gateway seams. See `CHANGELOG.md`.
239
+
240
+ MIT.
@@ -0,0 +1,215 @@
1
+ # arcaeon-adapter
2
+
3
+ **`arcaeon-ledger` proves a record wasn't altered. It can't prove the record is _complete_ — because the agent decides what to write. This moves the pen.**
4
+
5
+ `arcaeon-adapter` is a stdio proxy that sits between an MCP client and an MCP
6
+ server. It forwards JSON-RPC byte-for-byte, and on every `tools/call` it writes
7
+ one hash-chained row to its own ledger: which tool, digests of the arguments and
8
+ the result, ok or error, how long it took.
9
+
10
+ It runs in its own OS process. The agent is not consulted, cannot skip it, and
11
+ cannot see it.
12
+
13
+ ```
14
+ pip install arcaeon-adapter arcaeon-ledger
15
+ ```
16
+
17
+ Wrap any MCP server's command — one line of config, zero code:
18
+
19
+ ```json
20
+ { "mcpServers": {
21
+ "ledger": {
22
+ "command": "python",
23
+ "args": ["-m", "arcaeon_adapter", "--ledger", "seam.log.jsonl", "--",
24
+ "python", "-m", "arcaeon_ledger.mcp_server", "--log", "agent.log.jsonl"]
25
+ }
26
+ } }
27
+ ```
28
+
29
+ Or straight from a shell:
30
+
31
+ ```
32
+ python -m arcaeon_adapter --ledger seam.log.jsonl -- <your mcp server command...>
33
+ ```
34
+
35
+ ## What lands in the log
36
+
37
+ Real rows, from wrapping `arcaeon-ledger`'s own MCP server (the ledger logging
38
+ itself through the ledger), trimmed for width:
39
+
40
+ ```json
41
+ {"evt":"session_begin","seam":"mcp-stdio","seam_impl":"arcaeon-adapter/0.1.0",
42
+ "session":"25eda195-70e8-45cb-9035-be9f61cfc759","seq":1,
43
+ "server":"arcaeon_ledger.mcp_server","ledger_backend":"arcaeon-ledger/0.5.7",
44
+ "command":["python","-m","arcaeon_ledger.mcp_server","--log","agent.log.jsonl"],
45
+ "command_digest":"sha256:json-c14n:v1:ec06bd01…","raw_payloads":false,
46
+ "ts":"2026-08-19T14:18:08Z","chain":"0081ec1fe5d98395192f595e553b4133"}
47
+
48
+ {"evt":"tools_list","seq":3,"tools":["ledger_append","ledger_verify"],"tool_count":2,
49
+ "tools_digest":"sha256:json-c14n:v1:b85c9fce…","status":"ok", …}
50
+
51
+ {"evt":"tool_call","seq":4,"tool":"ledger_append","rpc_id":"3",
52
+ "args_digest":"sha256:json-c14n:v1:cfb2bb50…",
53
+ "result_digest":"sha256:json-c14n:v1:ba037f70…",
54
+ "status":"ok","ms":144, …}
55
+
56
+ {"evt":"session_end","seq":6,"reason":"child_exit","exit_code":0,
57
+ "rows_before_end":5,"calls":2, …}
58
+ ```
59
+
60
+ In that same run, the wrapped server's *own* log — the diary it keeps when the
61
+ agent remembers to call `ledger_append` — had **one** row. The seam log had six.
62
+ That difference is the entire product.
63
+
64
+ | field | why it's there |
65
+ |---|---|
66
+ | `evt` | `tool_call`, `session_begin`, `session_end`, `mcp_initialize`, `tools_list` |
67
+ | `seam` | always `"mcp-stdio"` — the **provenance tier**. A separate process saw this at the protocol level. A downstream verifier reads this field to tell infrastructure-grade capture from a cooperative in-process callback the agent could simply not call. |
68
+ | `session` / `seq` | one proxy process = one session; `seq` is a dense total order within it. `session_begin` + `session_end` bracket the run so a reviewer can pair a session's start and end. |
69
+ | `args_digest` / `result_digest` | `sha256:json-c14n:v1:<hex>` — self-describing, so a stranger holding only the row can reproduce the computation. |
70
+ | `status` / `ms` | `ok` / `error` / `unanswered`, and wall-clock duration. `error` covers **both** a JSON-RPC `error` and a `result` carrying `isError: true`. |
71
+
72
+ **Digests, not payloads, by default.** The row proves *which* bytes crossed the
73
+ seam without keeping them. An audit log that quietly becomes a copy of every
74
+ prompt and every result is a liability, not an asset — and a person-free core is
75
+ much easier to retain for years. `--raw` embeds the payloads for deployers who
76
+ own that risk; the digests stay either way.
77
+
78
+ **A call that never gets an answer still gets a row** (`status: "unanswered"`).
79
+ Otherwise "kill the server mid-call" would be a way to make an action leave no
80
+ trace at the seam, which is the exact hole this exists to close.
81
+
82
+ ## Verify it
83
+
84
+ The seam log is an ordinary `arcaeon-ledger` file:
85
+
86
+ ```
87
+ python -m arcaeon_ledger.cli verify seam.log.jsonl
88
+ ```
89
+
90
+ Edit a row, delete one, reorder them — every later link breaks and `verify` names
91
+ the exact line.
92
+
93
+ ## The honest limit
94
+
95
+ > **An adapter on one seam logs that seam completely — and nothing else.** An
96
+ > agent can still act around it: a direct HTTP call, an un-wrapped MCP server, a
97
+ > shell command never crosses this proxy and never hits this ledger. The
98
+ > second-set-of-books problem does not go away; no logging layer can force total
99
+ > honesty. What the adapter guarantees is narrower and real: *everything that
100
+ > crossed the instrumented seam is in the record, automatically, and the record
101
+ > proves itself.* Honesty is forced at the seams, not everywhere — not a lock, a
102
+ > neighborhood.
103
+
104
+ Specifically **blind** to:
105
+
106
+ - your harness's built-in tools (Bash, file edits, web fetch) — they never touch MCP
107
+ - any MCP server you did not wrap
108
+ - HTTP the agent makes natively
109
+ - the model's reasoning, which is not an action and leaves no wire trace
110
+ - frames larger than `--max-frame` (relayed fine; counted and reported in
111
+ `session_end` as `oversize_frames_unlogged`, never a silent gap)
112
+
113
+ And on compliance, plainly: **this does not make anyone compliant with
114
+ anything.** Regulations like the EU AI Act's Art. 12 place duties on a *provider*
115
+ or *deployer*, and duties land on people, not libraries. What this is: a
116
+ mechanism that makes recording at one seam automatic rather than voluntary,
117
+ which is a thing you would otherwise have to build. Retention policy, log
118
+ semantics, risk classification, and every other obligation remain yours. Anyone
119
+ selling you a package that "makes you compliant" is selling you a story.
120
+
121
+ Whoever controls the config can also remove the wrapper. That is the same class
122
+ of limit every logging layer has, and it is stated here rather than in a footnote.
123
+
124
+ ## Fidelity is the P0 property
125
+
126
+ A proxy that corrupts, reorders, or delays a customer's JSON-RPC is worse than no
127
+ proxy at all. So the design puts fidelity ahead of observation, structurally:
128
+
129
+ - **Bytes are forwarded first, observed second, from a copy.** A parsing defect
130
+ here cannot alter or withhold traffic; the worst it can do is produce a wrong
131
+ row.
132
+ - **Raw binary end to end.** No text mode anywhere — on Windows that would rewrite
133
+ `\n` as `\r\n` and silently alter every frame.
134
+ - **No reframing, ever.** We never parse a frame and re-emit it. The classic proxy
135
+ bug is `json.dumps(json.loads(frame))`: semantically identical, byte-different.
136
+ - **One thread per direction**, so ordering within a direction is the OS's.
137
+ - **The child's stderr is inherited, not piped.** Its diagnostics land exactly
138
+ where they did unwrapped, and the proxy itself prints nothing on an honest run.
139
+ - **Malformed frames are relayed untouched and not logged.** A server that prints
140
+ a stray line to stdout keeps working; we don't guess, because a guess in an
141
+ audit record is fiction.
142
+ - **The child's exit code is the proxy's exit code.**
143
+
144
+ This is tested by running a scripted client against a synthetic MCP server
145
+ directly, then through the proxy, and requiring the two stdout streams to be
146
+ identical *bytes* — including a 4 MB frame spanning ~64 pipe reads, unicode,
147
+ embedded CRLF, malformed lines, a notification with no id, and a final frame with
148
+ no trailing newline.
149
+
150
+ ## Prove the checks can fail
151
+
152
+ ```
153
+ python -m arcaeon_adapter.selftest
154
+ ```
155
+
156
+ An instrument that has never failed proves nothing. Every case runs GREEN on a
157
+ clean fixture, then **mutates** and requires the same check to go RED — with a
158
+ no-op guard in between, because a mutation that changes nothing proves nothing
159
+ either.
160
+
161
+ ```
162
+ PASS passthrough_fidelity GREEN: 2001202 bytes identical byte-for-byte through the proxy
163
+ PASS passthrough_fidelity RED on reserialize: corruption detected (length 2001202 vs 2001097)
164
+ PASS passthrough_fidelity RED on drop_byte: corruption detected (length 2001202 vs 2001168)
165
+ PASS one_row_per_call GREEN: 5 tools/call frames -> 5 tool_call rows, 9 rows total, seq 1..9
166
+ PASS one_row_per_call RED on log_on_request: count check caught 10 rows for 5 calls
167
+ PASS unanswered_call_logged GREEN: unanswered `quiet` call rowed with args_digest bound
168
+ PASS unanswered_call_logged RED on no_flush: dropping the shutdown flush loses the row
169
+ PASS tamper_detected GREEN: seam log verifies (ok=True, rows=9)
170
+ PASS tamper_detected RED on edited args_digest: first_break='line 4: chain mismatch'
171
+ PASS digest_recipe_frozen GREEN: 3 frozen json-c14n:v1 vectors reproduce exactly
172
+ PASS digest_recipe_frozen RED on unsorted_keys: drifted canonicalizer produces a different digest
173
+
174
+ ALL CHECKS PASSED — and every one was observed failing on its own defect.
175
+ ```
176
+
177
+ The fidelity mutations are injected into the **real relay**, not a mock, so what's
178
+ proven is that the comparison would catch a regression in the shipping code path.
179
+
180
+ ## Options
181
+
182
+ ```
183
+ --ledger PATH seam ledger (required). Keep it SEPARATE from any ledger the
184
+ wrapped server writes: one file, one writer, clean provenance.
185
+ --server NAME label for the wrapped server in every row (default: derived
186
+ from the command — `-m pkg.mod` reads as "pkg.mod", not "python")
187
+ --session ID session id (default: a fresh uuid4 per proxy process)
188
+ --raw embed raw argument and result payloads. Off by default.
189
+ --max-frame BYTES frames larger than this are relayed but not logged (default 64 MiB)
190
+ ```
191
+
192
+ ## Install and dependencies
193
+
194
+ Stdlib only at runtime. `arcaeon-ledger` is the intended writer and is what makes
195
+ a row provable — but it is **not** a hard dependency, on purpose: this gets
196
+ wrapped around somebody else's working MCP server by editing one line of config,
197
+ and a missing package must never be why their server fails to start. Without it,
198
+ rows are written by a byte-compatible fallback and `session_begin` says so in
199
+ `ledger_backend`, so a reviewer never has to guess which writer produced a chain.
200
+ (That byte-compatibility is asserted in the test suite, not just claimed here.)
201
+
202
+ **No network calls at runtime.** Ever.
203
+
204
+ ```
205
+ pytest -q # 65 tests
206
+ python -m arcaeon_adapter.selftest # the mutation harness
207
+ ```
208
+
209
+ ## Status
210
+
211
+ v0. Works, tested, dogfooded on our own MCP server. Deliberately not yet built:
212
+ `--authority` stamping, `--bind-inputs` artefact binding, `--auto-pin` witness
213
+ publication, and harness-hook / HTTP-gateway seams. See `CHANGELOG.md`.
214
+
215
+ MIT.
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """arcaeon-adapter — infrastructure-grade action logging at the MCP stdio seam.
3
+
4
+ `arcaeon-ledger` proves a record wasn't altered. It cannot prove the record is
5
+ *complete*, because `append()` records whatever a caller chooses to pass: the
6
+ agent holds the pen. This package moves the pen into the pipe the agent's actions
7
+ flow through — a stdio proxy, in its own OS process, that sees every `tools/call`
8
+ and every response at the protocol level and writes a hash-chained row per pair
9
+ without the agent's cooperation, knowledge, or consent.
10
+
11
+ python -m arcaeon_adapter.proxy --ledger seam.log.jsonl -- <mcp server command...>
12
+ python -m arcaeon_adapter.selftest # every claimed check, observed failing
13
+
14
+ The honest limit travels with the claim, always: an adapter on one seam logs that
15
+ seam completely and nothing else. A direct HTTP call, an un-wrapped server, or a
16
+ shell command never crosses this proxy and never lands in this ledger. This is
17
+ not a lock. It is a neighborhood.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ from ._version import IMPL, VERSION
24
+ from .observer import SEAM, FrameSplitter, SeamObserver
25
+
26
+ __version__ = VERSION
27
+ __all__ = ["SEAM", "IMPL", "VERSION", "__version__",
28
+ "FrameSplitter", "SeamObserver", "main", "relay", "run"]
29
+
30
+ # `proxy` is imported LAZILY, and that is load-bearing rather than tidy.
31
+ # `python -m arcaeon_adapter.proxy` runs this package's __init__ first; if we
32
+ # imported .proxy here, runpy would then find it already in sys.modules and print
33
+ # RuntimeWarning: 'arcaeon_adapter.proxy' found in sys.modules ...
34
+ # to STDERR — on every single launch. The proxy inherits the wrapped server's
35
+ # stderr, so that warning lands in the customer's server log, from a tool whose
36
+ # entire pitch is that it changes nothing about how their server behaves. A
37
+ # logging sidecar that dirties the logs is a bad joke. PEP 562 lazy attributes
38
+ # keep `from arcaeon_adapter import run` working without the import at load time.
39
+ _LAZY = {"main", "relay", "run"}
40
+
41
+
42
+ def __getattr__(name: str) -> Any:
43
+ if name in _LAZY:
44
+ from . import proxy
45
+ return getattr(proxy, name)
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
47
+
48
+
49
+ def __dir__() -> list:
50
+ return sorted(__all__)
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """`python -m arcaeon_adapter` == `python -m arcaeon_adapter.proxy`.
3
+
4
+ The shorter form is what goes in an MCP client config, where the line is already
5
+ long enough. Both spellings are supported forever; config files outlive advice.
6
+ """
7
+ import sys
8
+
9
+ from .proxy import main
10
+
11
+ if __name__ == "__main__":
12
+ sys.exit(main())