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.
- arcaeon_adapter-0.1.0/.gitignore +8 -0
- arcaeon_adapter-0.1.0/CHANGELOG.md +105 -0
- arcaeon_adapter-0.1.0/LICENSE +21 -0
- arcaeon_adapter-0.1.0/PKG-INFO +240 -0
- arcaeon_adapter-0.1.0/README.md +215 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/__init__.py +50 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/__main__.py +12 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/_echo_server.py +121 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/_ledger.py +231 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/_version.py +11 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/observer.py +395 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/proxy.py +398 -0
- arcaeon_adapter-0.1.0/arcaeon_adapter/selftest.py +421 -0
- arcaeon_adapter-0.1.0/pyproject.toml +49 -0
- arcaeon_adapter-0.1.0/test_ledger_binding.py +119 -0
- arcaeon_adapter-0.1.0/test_observer.py +321 -0
- arcaeon_adapter-0.1.0/test_proxy.py +329 -0
|
@@ -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())
|