arcaeon 0.1.2__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.
- arcaeon-0.1.2.dist-info/METADATA +224 -0
- arcaeon-0.1.2.dist-info/RECORD +12 -0
- arcaeon-0.1.2.dist-info/WHEEL +5 -0
- arcaeon-0.1.2.dist-info/entry_points.txt +2 -0
- arcaeon-0.1.2.dist-info/licenses/LICENSE +21 -0
- arcaeon-0.1.2.dist-info/top_level.txt +1 -0
- arcaeon_connector/__init__.py +28 -0
- arcaeon_connector/__main__.py +47 -0
- arcaeon_connector/licensing.py +132 -0
- arcaeon_connector/offers.py +69 -0
- arcaeon_connector/server.py +324 -0
- arcaeon_connector/witness.py +98 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arcaeon
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Arcaeon's tools on one MCP connector: tamper-evident agent ledger, MCP-server source checker, hosted witness. Free to install.
|
|
5
|
+
Author: Arcaeon
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://arcaeon.io
|
|
8
|
+
Project-URL: Offers, https://arcaeon.io/.well-known/offers.json
|
|
9
|
+
Keywords: mcp,model-context-protocol,agents,audit,tamper-evident,provenance,security
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: mcp>=1.0
|
|
20
|
+
Requires-Dist: arcaeon-ledger>=0.7.0
|
|
21
|
+
Requires-Dist: arcaeon-mcp-vet>=0.0.8
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# arcaeon
|
|
27
|
+
|
|
28
|
+
**One MCP connector for the whole Arcaeon toolbox.** Install once, wire one
|
|
29
|
+
stanza into your client, get eleven tools: a tamper-evident agent ledger, a
|
|
30
|
+
static checker for MCP-server source, and the hosted witness that catches
|
|
31
|
+
truncation.
|
|
32
|
+
|
|
33
|
+
Free to install. Free to use, except the two tools that spend money on our side,
|
|
34
|
+
and those tell you the price in plain English instead of failing.
|
|
35
|
+
|
|
36
|
+
Arcaeon ships a shelf of small single-purpose packages. A shelf is a
|
|
37
|
+
distribution problem: an agent that would use three of them has to find three,
|
|
38
|
+
install three, and wire three stanzas. Most never get past the first. This is
|
|
39
|
+
the one door.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install arcaeon
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Today that command does not work yet, and this README is not going to pretend
|
|
50
|
+
otherwise.** The connector depends on `arcaeon-ledger>=0.7.0` and
|
|
51
|
+
`arcaeon-mcp-vet>=0.0.8`; PyPI currently has arcaeon-ledger 0.5.9 and no mcp-vet at all.
|
|
52
|
+
Until both are published, install the two from source first:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -e path/to/arcaeon-ledger # 0.7.0 — the agent tools live here
|
|
56
|
+
pip install -e path/to/mcp_vet # 0.0.7
|
|
57
|
+
pip install -e path/to/arcaeon_connector # this package
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The dependency pins are declared honestly rather than loosened to whatever PyPI
|
|
61
|
+
happens to hold: a `>=0.5.9` that installs and then fails at import is worse
|
|
62
|
+
than a resolver error that says what is missing.
|
|
63
|
+
|
|
64
|
+
Check the install without starting a server:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
arcaeon-mcp --tools
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Wire it into a client
|
|
71
|
+
|
|
72
|
+
`.mcp.json` (Claude Code and friends):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"mcpServers": {
|
|
77
|
+
"arcaeon": {
|
|
78
|
+
"command": "arcaeon-mcp",
|
|
79
|
+
"args": ["--log", "agent.log.jsonl"],
|
|
80
|
+
"env": {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
With the paid lane switched on, and the ledger somewhere deliberate:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"arcaeon": {
|
|
92
|
+
"command": "arcaeon-mcp",
|
|
93
|
+
"args": ["--log", "state/agent.log.jsonl", "--ns-dir", "state/ledgers"],
|
|
94
|
+
"env": {
|
|
95
|
+
"ARCAEON_KEY": "your-witness-key"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
If your client cannot run console scripts, `"command": "python", "args": ["-m",
|
|
103
|
+
"arcaeon_connector"]` is the same server.
|
|
104
|
+
|
|
105
|
+
## The tools
|
|
106
|
+
|
|
107
|
+
| Tool | Cost | What it does |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `ledger_append` | free | Append one action record to a hash-chained log; returns its chain hash. |
|
|
110
|
+
| `ledger_verify` | free | Verify the chain. Three-valued: `true` every row verified, `null` the scan was bounded (**not a green**), `false` names the exact broken line. |
|
|
111
|
+
| `ledger_prove_my_conduct` | free | Log a batch to your own named ledger, get back one head hash to hand your principal. |
|
|
112
|
+
| `ledger_verify_peer_ledger` | free | Judge ANOTHER agent's exported log from its text alone. No access to their machine, no writes on yours. |
|
|
113
|
+
| `ledger_declare_break` | free | Your log broke. Name the break instead of re-minting a chain that verifies. |
|
|
114
|
+
| `vet_scan` | free | Statically scan a Python MCP server's source; findings with exact line numbers. |
|
|
115
|
+
| `vet_grade` | free | The full re-testable grade artifact: `source_sha256`, findings, checks run, declared blind spots, verdict. |
|
|
116
|
+
| `vet_audit_verify` | free | Recompute the hash chain over mcp-vet's own call-record ledger; three-valued `ok`, `rows`, `breaks`, `first_break`. |
|
|
117
|
+
| `witness_pin` | **paid** | Pin your ledger head with a party you cannot advance. The only thing that catches truncation. |
|
|
118
|
+
| `witness_renew` | **paid** | Restate an unchanged head so a finished log stops looking abandoned. |
|
|
119
|
+
| `arcaeon_status` | free | Versions, the free/paid split, whether a key is set, where the ledger is. |
|
|
120
|
+
|
|
121
|
+
Names are prefixed by which product answers: `ledger_*`, `vet_*`, `witness_*`.
|
|
122
|
+
|
|
123
|
+
## The paid lane
|
|
124
|
+
|
|
125
|
+
Two tools need `ARCAEON_KEY`, because a hosted pin is a commit somebody pays
|
|
126
|
+
for. Called without a key they return a plain sentence:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
witness_pin is a paid Arcaeon tool and no ARCAEON_KEY is set, so nothing was sent.
|
|
130
|
+
|
|
131
|
+
It pins your ledger head with the hosted witness (https://witness.arcaeon.io): a
|
|
132
|
+
party you cannot advance, which is the only thing that catches truncation.
|
|
133
|
+
|
|
134
|
+
Free tier: 100 pins/month, no card. To get one: email hello@arcaeon.io or ask
|
|
135
|
+
Nora for a key.
|
|
136
|
+
Entry pack: $5 for 1,000 pins ($0.005/pin): https://buy.stripe.com/aFa4gAb10ead3xy35f0RG08
|
|
137
|
+
...
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
No stack trace, no silent nothing, and the free door named before the paid one —
|
|
141
|
+
the witness free tier is 100 pins/month with no card, and the witness library
|
|
142
|
+
itself is self-hostable free forever (point `ARCAEON_WITNESS_URL` at your own
|
|
143
|
+
deployment and the same two tools work). The catalog those numbers come from is
|
|
144
|
+
[`/.well-known/offers.json`](https://arcaeon.io/.well-known/offers.json), and a
|
|
145
|
+
test in this repo fails if the copy in the code drifts from it.
|
|
146
|
+
|
|
147
|
+
## Environment
|
|
148
|
+
|
|
149
|
+
| Variable | Default | Meaning |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `ARCAEON_KEY` | unset | Witness bearer key. Unset means the two paid tools explain themselves instead of running. |
|
|
152
|
+
| `ARCAEON_LEDGER_LOG` | `agent.log.jsonl` | The ledger `ledger_append` / `ledger_verify` write. Same as `--log`. |
|
|
153
|
+
| `ARCAEON_LEDGER_NS_DIR` | `ledgers/` beside the log | Per-namespace agent ledgers. Same as `--ns-dir`. |
|
|
154
|
+
| `ARCAEON_WITNESS_URL` | `https://witness.arcaeon.io` | Point the witness tools at your own self-hosted deployment. |
|
|
155
|
+
| `LICENSE_GATE_REQUIRED` | unset (off) | Set to `1` to also require a license key on the two paid tools. Off by default; see below. |
|
|
156
|
+
| `ARCAEON_LICENSE_KEY` | unset | The license key, when the gate is on. Bound to a ledger namespace, not to a machine. |
|
|
157
|
+
| `LICENSE_GATE_MODULE` | auto | Override which module implements the gate. Only useful to a self-hoster or a test. |
|
|
158
|
+
|
|
159
|
+
## The optional license gate (off by default)
|
|
160
|
+
|
|
161
|
+
A second, optional gate sits in front of the same two paid tools and answers a
|
|
162
|
+
different question: not "does this caller have a witness account" (that is
|
|
163
|
+
`ARCAEON_KEY`) but "is this copy of the package licensed". It is **inert unless
|
|
164
|
+
you set `LICENSE_GATE_REQUIRED=1`** - unset, nothing here imports, nothing here
|
|
165
|
+
runs, and the connector behaves exactly as it did before this existed.
|
|
166
|
+
|
|
167
|
+
When it is on, the license is bound to the **ledger namespace being pinned**.
|
|
168
|
+
That is the whole idea: a borrowed key would have to pin under the lender's
|
|
169
|
+
namespace, into the lender's public pin history, under the lender's name. The
|
|
170
|
+
gate does not prevent that; it makes it self-incriminating, which for people
|
|
171
|
+
who buy audit tooling is the part that bites.
|
|
172
|
+
|
|
173
|
+
It **fails closed**. `LICENSE_GATE_REQUIRED=1` with no gate module installed
|
|
174
|
+
refuses the paid tools rather than waving them through, because a required
|
|
175
|
+
check that passes because its own implementation is missing looks enforced and
|
|
176
|
+
is nothing.
|
|
177
|
+
|
|
178
|
+
Honest limit, stated here as well as in the gate's own README: a client-side
|
|
179
|
+
license check deters, it does not prevent. Anyone who can edit the package can
|
|
180
|
+
delete the call. The real moat is updates and the ledger identity, not this.
|
|
181
|
+
|
|
182
|
+
## What this does NOT prove
|
|
183
|
+
|
|
184
|
+
Inherited from the packages it bundles, restated here because a bundle that
|
|
185
|
+
drops the caveats is selling a stronger claim than its parts:
|
|
186
|
+
|
|
187
|
+
- **Tamper-evidence is not truth.** The ledger proves a record was not altered.
|
|
188
|
+
It says nothing about whether what it records was correct, or whether the
|
|
189
|
+
agent that wrote it was honest at the time.
|
|
190
|
+
- **A clean `vet_scan` is not "safe".** It means a small set of documented
|
|
191
|
+
failure classes found nothing. mcp-vet publishes its own blind spots inside
|
|
192
|
+
every grade, including one it demonstrates on its own server.
|
|
193
|
+
- **A pin proves no-truncation only relative to what the witness saw, and only
|
|
194
|
+
as recently as the last pin.** The pin gap is the security parameter.
|
|
195
|
+
- **Witness auth is bearer-key only** (`auth_level: "bearer-stage0"`). A leaked
|
|
196
|
+
key can pin and renew in your name. Owner-signature auth is designed
|
|
197
|
+
(STAGE1_SIGNATURE_DESIGN) and not built.
|
|
198
|
+
- **This connector adds no analysis of its own.** It re-exports; the ledger
|
|
199
|
+
tools dispatch straight into `arcaeon_ledger.mcp_server.handle`, the same
|
|
200
|
+
function the standalone server runs. If a result here disagrees with the
|
|
201
|
+
standalone server, that is a bug in this package, and one of the tests exists
|
|
202
|
+
to catch exactly that shape of drift.
|
|
203
|
+
|
|
204
|
+
## Run the tests
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pip install -e ".[dev]"
|
|
208
|
+
pytest -q
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Fifteen tests. Every one drives a real MCP round-trip; one of them spawns the
|
|
212
|
+
installed entry point as a subprocess and speaks raw JSON-RPC down the pipe,
|
|
213
|
+
because "one install and it works" is a claim about the package, not about an
|
|
214
|
+
importable module.
|
|
215
|
+
|
|
216
|
+
## Security note
|
|
217
|
+
|
|
218
|
+
Local stdio only. The tools read caller-named paths, so do not put this behind a
|
|
219
|
+
network transport without an auth layer in front — which is what `vet_scan`'s
|
|
220
|
+
own `zero-auth` check would tell you about anyone else's server.
|
|
221
|
+
|
|
222
|
+
MIT licensed. Every product Arcaeon charges for is listed in
|
|
223
|
+
[`/.well-known/offers.json`](https://arcaeon.io/.well-known/offers.json); a
|
|
224
|
+
charge that is not in that file is not ours.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
arcaeon-0.1.2.dist-info/licenses/LICENSE,sha256=X891VWc5gAsgzxxpN3G8LJ2dvQDKca9SUivd0m2IftQ,1064
|
|
2
|
+
arcaeon_connector/__init__.py,sha256=8fF1DhtH7yAN32pxjhN5phsKgrCZ7ERJUo6BDBZH7Lg,1564
|
|
3
|
+
arcaeon_connector/__main__.py,sha256=2vB48CeCmkvkSCBjIUs8r0VZbZycgHJI81X2kOwXMKM,1674
|
|
4
|
+
arcaeon_connector/licensing.py,sha256=FjtNHoFlzaLER71vw3FgvDKNfuYm1TG9cR1yrnPqmdY,5341
|
|
5
|
+
arcaeon_connector/offers.py,sha256=GtYp380AG4Jj19MjK_7qT0PnpPPenj8ROI0EA-7JQD8,3321
|
|
6
|
+
arcaeon_connector/server.py,sha256=SGV_IRHruGxx7gs9aTxLMSY6NY13AVgqlwdB2x9p9Tg,15539
|
|
7
|
+
arcaeon_connector/witness.py,sha256=Fs0IQfr-4x_YhNbBaFCG_cLLWQn93vUTutziZ4jcGYg,4069
|
|
8
|
+
arcaeon-0.1.2.dist-info/METADATA,sha256=Bm01L7221ZbayazCRrFObC7JfMsAYfhBdC8PnGNpU3U,10063
|
|
9
|
+
arcaeon-0.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
arcaeon-0.1.2.dist-info/entry_points.txt,sha256=asoe6cYIcW-kMll1d0eNW7W-pAgzAkhgOBV8mRxynb0,64
|
|
11
|
+
arcaeon-0.1.2.dist-info/top_level.txt,sha256=sEOs8zu4WQD0AnBGLiAEije5pUKstARXw4rEq6vSqrs,18
|
|
12
|
+
arcaeon-0.1.2.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
arcaeon_connector
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""arcaeon — the Arcaeon connector. One MCP server, the whole toolbox.
|
|
2
|
+
|
|
3
|
+
Arcaeon ships a shelf of small, single-purpose packages, and a shelf is a
|
|
4
|
+
distribution problem: an agent that would use three of them has to find three
|
|
5
|
+
of them, install three of them, and wire three stanzas into its client config.
|
|
6
|
+
Most never get past the first. This package is the one door — `pip install
|
|
7
|
+
arcaeon`, one stdio server, every tool on one list:
|
|
8
|
+
|
|
9
|
+
ledger_* arcaeon-ledger, re-exported whole (append, verify, and the
|
|
10
|
+
agent-facing prove_my_conduct / verify_peer_ledger /
|
|
11
|
+
declare_break)
|
|
12
|
+
vet_* mcp-vet, the static checker for MCP-server source
|
|
13
|
+
witness_* the hosted witness's pin/renew — the PAID lane
|
|
14
|
+
arcaeon_status versions, and which of the above costs money
|
|
15
|
+
|
|
16
|
+
It re-exports; it does not reimplement. The ledger tools dispatch into
|
|
17
|
+
`arcaeon_ledger.mcp_server.handle` — the same function the standalone server
|
|
18
|
+
runs — and their descriptions are read off that package's own TOOLS list, so
|
|
19
|
+
the text a client reads is the text upstream wrote. The vet tools call
|
|
20
|
+
`mcp_vet`'s scanner directly. Two verifiers that can disagree is one verifier
|
|
21
|
+
too many; the same rule applies to two copies of a tool.
|
|
22
|
+
|
|
23
|
+
FREE by default, and the free part is the large part. The gate covers exactly
|
|
24
|
+
the two tools that spend money on our side (a hosted pin is a GitHub commit
|
|
25
|
+
somebody pays for), and when it fires it hands back a plain sentence with the
|
|
26
|
+
price and the link — never a stack trace, never a silent nothing.
|
|
27
|
+
"""
|
|
28
|
+
__version__ = "0.1.2"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""`arcaeon-mcp` — the console entry point.
|
|
2
|
+
|
|
3
|
+
Flags mirror the standalone ledger server's (`--log`, `--ns-dir`) because the
|
|
4
|
+
people wiring this in have already read that runbook. They are written into the
|
|
5
|
+
environment rather than threaded through, so a client that prefers env config
|
|
6
|
+
(`ARCAEON_LEDGER_LOG`) and a client that prefers args land in the same place,
|
|
7
|
+
and there is only one resolution path to reason about.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None) -> int:
|
|
18
|
+
ap = argparse.ArgumentParser(
|
|
19
|
+
prog="arcaeon-mcp",
|
|
20
|
+
description="Arcaeon's tools on one MCP stdio server (ledger, vet, witness).")
|
|
21
|
+
ap.add_argument("--log", default=None,
|
|
22
|
+
help="ledger file path (default: agent.log.jsonl, or $ARCAEON_LEDGER_LOG)")
|
|
23
|
+
ap.add_argument("--ns-dir", default=None,
|
|
24
|
+
help="directory for the per-namespace agent ledgers "
|
|
25
|
+
"(default: ledgers/ beside --log)")
|
|
26
|
+
ap.add_argument("--tools", action="store_true",
|
|
27
|
+
help="print the tool list and the free/paid split, then exit "
|
|
28
|
+
"(no server, no stdio) — for checking an install")
|
|
29
|
+
args = ap.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
if args.log:
|
|
32
|
+
os.environ["ARCAEON_LEDGER_LOG"] = args.log
|
|
33
|
+
if args.ns_dir:
|
|
34
|
+
os.environ["ARCAEON_LEDGER_NS_DIR"] = args.ns_dir
|
|
35
|
+
|
|
36
|
+
from .server import FREE_TOOLS, PAID_TOOLS, serve
|
|
37
|
+
|
|
38
|
+
if args.tools:
|
|
39
|
+
print(json.dumps({"free": list(FREE_TOOLS), "paid": list(PAID_TOOLS)}, indent=2))
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
serve()
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__": # pragma: no cover
|
|
47
|
+
sys.exit(main())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Optional license entanglement for the paid lane. OFF unless asked for.
|
|
2
|
+
|
|
3
|
+
Idea I-daniel-17. The connector's paid tools are already gated on ARCAEON_KEY,
|
|
4
|
+
which answers "does this caller have a witness account". This answers a
|
|
5
|
+
different question — "is this copy of the package licensed" — and it answers it
|
|
6
|
+
by binding a license key to the LEDGER NAMESPACE being pinned. A borrowed key
|
|
7
|
+
would have to pin under the lender's namespace, which puts the borrower's rows
|
|
8
|
+
in someone else's public pin history under someone else's name. That is the
|
|
9
|
+
entanglement; the gate itself is just the polite door in front of it.
|
|
10
|
+
|
|
11
|
+
THREE PROPERTIES, on purpose:
|
|
12
|
+
|
|
13
|
+
DEFAULT OFF. With LICENSE_GATE_REQUIRED unset (or 0/false/no), nothing here
|
|
14
|
+
imports anything and every call returns None in a few microseconds. The
|
|
15
|
+
connector ships free and behaves exactly as it did before this file existed.
|
|
16
|
+
|
|
17
|
+
OPTIONAL IMPORT. The gate lives outside this package (a pip install of
|
|
18
|
+
`arcaeon` does not carry the velouria repo), so it is resolved at call time
|
|
19
|
+
by module name across a small candidate list, overridable with
|
|
20
|
+
LICENSE_GATE_MODULE. A missing gate is not an error while the gate is off.
|
|
21
|
+
|
|
22
|
+
FAILS CLOSED WHEN ON. LICENSE_GATE_REQUIRED=1 with no gate module installed
|
|
23
|
+
REFUSES the paid tools. A required check that silently passes because its
|
|
24
|
+
own implementation is missing is the worst of the three outcomes: it looks
|
|
25
|
+
enforced and is not.
|
|
26
|
+
|
|
27
|
+
Refusals are returned as plain sentences, never raised — same contract as the
|
|
28
|
+
no-key upgrade message. In this connector a refusal is a product surface.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import importlib
|
|
33
|
+
import os
|
|
34
|
+
|
|
35
|
+
#: Set to 1/true/yes to turn the gate on. Anything else, including unset, is off.
|
|
36
|
+
REQUIRED_ENV = "LICENSE_GATE_REQUIRED"
|
|
37
|
+
|
|
38
|
+
#: Where the buyer's license key lives. Distinct from ARCAEON_KEY, which is a
|
|
39
|
+
#: witness account credential and answers a different question.
|
|
40
|
+
KEY_ENV = "ARCAEON_LICENSE_KEY"
|
|
41
|
+
|
|
42
|
+
#: Optional explicit module path, for a self-hoster or a test.
|
|
43
|
+
MODULE_ENV = "LICENSE_GATE_MODULE"
|
|
44
|
+
|
|
45
|
+
#: Tried in order. The first is the eventual shipped package; the second is
|
|
46
|
+
#: where the implementation lives in the velouria repo today.
|
|
47
|
+
GATE_MODULES = ("arcaeon_license_gate", "bridge.license_gate.gate")
|
|
48
|
+
|
|
49
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def required() -> bool:
|
|
53
|
+
"""Read at CALL time, never cached: a client can change its environment
|
|
54
|
+
between sessions, and a cached 'off' is a gate that never turns on."""
|
|
55
|
+
return os.environ.get(REQUIRED_ENV, "").strip().lower() in _TRUE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_gate():
|
|
59
|
+
"""The gate module, or None if nothing importable provides one."""
|
|
60
|
+
names = [n for n in (os.environ.get(MODULE_ENV, "").strip(),) if n] or list(GATE_MODULES)
|
|
61
|
+
for name in names:
|
|
62
|
+
try:
|
|
63
|
+
return importlib.import_module(name)
|
|
64
|
+
except ImportError:
|
|
65
|
+
continue
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def refusal_for(tool: str, identity: str) -> str | None:
|
|
70
|
+
"""None when the call may proceed; a plain sentence when it may not.
|
|
71
|
+
|
|
72
|
+
`identity` is the ledger namespace the paid call is about. Tying the
|
|
73
|
+
license to the namespace rather than to a machine or a user account is the
|
|
74
|
+
whole idea: the license covers YOUR ledger.
|
|
75
|
+
"""
|
|
76
|
+
if not required():
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
gate = load_gate()
|
|
80
|
+
if gate is None:
|
|
81
|
+
return (
|
|
82
|
+
f"{tool} is refused: {REQUIRED_ENV}=1 is set on this machine but no "
|
|
83
|
+
f"license gate module is installed, so the license could not be "
|
|
84
|
+
f"checked at all.\n"
|
|
85
|
+
f"\n"
|
|
86
|
+
f"This refusal is deliberate. A required check that passes because "
|
|
87
|
+
f"its own implementation is missing would look enforced and be "
|
|
88
|
+
f"nothing. Install the gate, or unset {REQUIRED_ENV} to run the "
|
|
89
|
+
f"connector in its normal free-and-ungated mode."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
gate.check(os.environ.get(KEY_ENV, ""), identity)
|
|
94
|
+
except gate.LicenseError as e:
|
|
95
|
+
return _refusal_text(tool, identity, e)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _refusal_text(tool: str, identity: str, error) -> str:
|
|
100
|
+
"""The gate's own sentence, wrapped with what the caller needs to act.
|
|
101
|
+
|
|
102
|
+
ASCII only, like every other refusal here: this gets printed to terminals,
|
|
103
|
+
and a cp1252 console turns a well-meant em-dash into a replacement glyph.
|
|
104
|
+
"""
|
|
105
|
+
reason = getattr(error, "reason", "refused")
|
|
106
|
+
return (
|
|
107
|
+
f"{tool} is refused by the license gate [{reason}], so nothing was sent.\n"
|
|
108
|
+
f"\n"
|
|
109
|
+
f"{error}\n"
|
|
110
|
+
f"\n"
|
|
111
|
+
f"The license is bound to the ledger namespace being pinned, which here "
|
|
112
|
+
f"is {identity!r}. Set {KEY_ENV}=<your license key>, or unset "
|
|
113
|
+
f"{REQUIRED_ENV} if this copy is not meant to be gated."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def status() -> dict:
|
|
118
|
+
"""What arcaeon_status reports about licensing. Never includes the key."""
|
|
119
|
+
on = required()
|
|
120
|
+
gate = load_gate() if on else None
|
|
121
|
+
return {
|
|
122
|
+
"required": on,
|
|
123
|
+
"required_env": REQUIRED_ENV,
|
|
124
|
+
"key_env": KEY_ENV,
|
|
125
|
+
"key_present": bool(os.environ.get(KEY_ENV, "").strip()),
|
|
126
|
+
"gate_module": getattr(gate, "__name__", None),
|
|
127
|
+
"note": (
|
|
128
|
+
"Off by default. When on, the paid tools additionally require a "
|
|
129
|
+
"license key bound to the ledger namespace being pinned; a missing "
|
|
130
|
+
"gate module fails closed."
|
|
131
|
+
),
|
|
132
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""What Arcaeon charges for, snapshotted from the one place of record.
|
|
2
|
+
|
|
3
|
+
The source of truth is the site's `.well-known/offers.json`, whose own honesty
|
|
4
|
+
contract says: "This file is the single source of truth for what Arcaeon
|
|
5
|
+
charges for ... A charge that does not correspond to an offer in this file is
|
|
6
|
+
not ours." A pip-installed package cannot read that file, so the two facts the
|
|
7
|
+
upgrade message needs — the entry pack and the witness endpoint — are copied
|
|
8
|
+
here, and `test_offers_drift.py` fails on the workstation if the copy stops
|
|
9
|
+
matching the catalog. Snapshot date: 2026-08-30.
|
|
10
|
+
|
|
11
|
+
Everything else in this connector is free, permanently, and says so.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# hosted-witness, tier plan="mini": the smallest honest batch above the card-fee
|
|
16
|
+
# floor. Same per-pin price as Starter ($0.005); it exists so the entry price is
|
|
17
|
+
# five dollars and not fifteen.
|
|
18
|
+
MINI_PACK = {
|
|
19
|
+
"plan": "mini",
|
|
20
|
+
"price_usd": 5,
|
|
21
|
+
"pins": 1000,
|
|
22
|
+
"price_per_pin_usd": 0.005,
|
|
23
|
+
"checkout": "https://buy.stripe.com/aFa4gAb10ead3xy35f0RG08",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# The free tier is real and is NOT a trial: 100 pins/month, no card. It is named
|
|
27
|
+
# in the upgrade message on purpose — a paywall that hides the free door is
|
|
28
|
+
# selling something the catalog says is free.
|
|
29
|
+
FREE_TIER = {"plan": "free", "price_usd": 0, "pins_per_month": 100,
|
|
30
|
+
"how": "email hello@arcaeon.io or ask Nora for a key"}
|
|
31
|
+
|
|
32
|
+
WITNESS_ENDPOINT = "https://witness.arcaeon.io"
|
|
33
|
+
|
|
34
|
+
CATALOG_URL = "https://arcaeon.io/.well-known/offers.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def upgrade_message(tool: str, free_tools: list[str] | None = None) -> str:
|
|
38
|
+
"""The plain sentence a caller gets instead of an error when a paid tool is
|
|
39
|
+
invoked with no key.
|
|
40
|
+
|
|
41
|
+
Deliberately plain text and deliberately complete: what it costs, where the
|
|
42
|
+
free door is, what to set, and what still works for free right now. An
|
|
43
|
+
agent reading this should be able to act on it without a second call, and a
|
|
44
|
+
human reading it should not have to guess whether they just hit a bug.
|
|
45
|
+
"""
|
|
46
|
+
# ASCII only, on purpose. This string is the one thing here that gets PRINTED
|
|
47
|
+
# to a terminal rather than rendered by a client, and a Windows console at
|
|
48
|
+
# cp1252 turns a well-meant em-dash into a replacement glyph. A refusal
|
|
49
|
+
# message that arrives visibly corrupted reads like the bug it is denying.
|
|
50
|
+
lines = [
|
|
51
|
+
f"{tool} is a paid Arcaeon tool and no ARCAEON_KEY is set, so nothing was sent.",
|
|
52
|
+
"",
|
|
53
|
+
f"It pins your ledger head with the hosted witness ({WITNESS_ENDPOINT}): a party "
|
|
54
|
+
"you cannot advance, which is the only thing that catches truncation.",
|
|
55
|
+
"",
|
|
56
|
+
f"Free tier: {FREE_TIER['pins_per_month']} pins/month, no card. "
|
|
57
|
+
f"To get one: {FREE_TIER['how']}.",
|
|
58
|
+
f"Entry pack: ${MINI_PACK['price_usd']} for {MINI_PACK['pins']:,} pins "
|
|
59
|
+
f"(${MINI_PACK['price_per_pin_usd']}/pin): {MINI_PACK['checkout']}",
|
|
60
|
+
"",
|
|
61
|
+
"Then set ARCAEON_KEY=<your witness key> in this server's environment and call again.",
|
|
62
|
+
f"Full price list: {CATALOG_URL}",
|
|
63
|
+
]
|
|
64
|
+
if free_tools:
|
|
65
|
+
lines += [
|
|
66
|
+
"",
|
|
67
|
+
"Free in this same install, no key needed: " + ", ".join(sorted(free_tools)) + ".",
|
|
68
|
+
]
|
|
69
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""The one server. Every Arcaeon tool on one list, over stdio.
|
|
2
|
+
|
|
3
|
+
RE-EXPORT, NOT REIMPLEMENTATION. The ledger tools dispatch into
|
|
4
|
+
`arcaeon_ledger.mcp_server.handle` — literally the function the standalone
|
|
5
|
+
ledger server runs on every call — and take their descriptions off that
|
|
6
|
+
package's own `TOOLS` list. The vet tools call `mcp_vet.server.scan_recorded` /
|
|
7
|
+
`grade_recorded` — the same read-scan/grade-record sequence mcp-vet's own
|
|
8
|
+
`mcp_vet_scan` / `mcp_vet_grade` tool handlers run, exposed as library calls —
|
|
9
|
+
and take their descriptions off `mcp_vet.server`'s own `SCAN_DESCRIPTION` /
|
|
10
|
+
`GRADE_DESCRIPTION` constants. (Board C-agent-31, 2026-08-30: this used to call
|
|
11
|
+
`mcp_vet.checks.scan_source` / `mcp_vet.grade.grade_source` straight, which
|
|
12
|
+
scans and grades correctly but never writes to mcp-vet's audit ledger — a vet
|
|
13
|
+
call through the connector left no row while the identical call through
|
|
14
|
+
mcp-vet's own server did. `scan_recorded` / `grade_recorded` close that gap at
|
|
15
|
+
the layer that owns the ledger.) Nothing here re-derives a result that one of
|
|
16
|
+
those packages already knows how to produce, because the day two copies
|
|
17
|
+
disagree is the day the connector lies about a chain.
|
|
18
|
+
|
|
19
|
+
NAMESPACING. Upstream names that already read as ledger tools keep their names
|
|
20
|
+
(`ledger_append`, `ledger_verify`); the agent-facing three get the prefix
|
|
21
|
+
(`ledger_prove_my_conduct`, and so on), and mcp-vet's `mcp_vet_*` becomes
|
|
22
|
+
`vet_*`. One list of eleven tools where the prefix says which product answers.
|
|
23
|
+
|
|
24
|
+
THE DRIFT RISK, stated. The wrappers are written out by hand rather than
|
|
25
|
+
generated, because the SDK derives a tool's JSON schema from a real Python
|
|
26
|
+
signature and a synthesized one is a second schema that can disagree with
|
|
27
|
+
upstream's. The cost is that a new upstream tool does not appear here for free.
|
|
28
|
+
That cost is paid by a test: `test_every_underlying_ledger_tool_is_re_exported`
|
|
29
|
+
walks upstream's TOOLS and goes red naming anything this file forgot. A bundler
|
|
30
|
+
that silently ships less than it bundles is the failure mode; it fails loudly
|
|
31
|
+
instead.
|
|
32
|
+
|
|
33
|
+
THE PAID LANE. Exactly two tools spend money on our side, and both are gated on
|
|
34
|
+
ARCAEON_KEY. With no key they return a plain sentence naming the free tier, the
|
|
35
|
+
$5 pack and the link — the refusal is a product surface, not an exception.
|
|
36
|
+
|
|
37
|
+
THE LICENSE GATE (idea I-daniel-17), OFF BY DEFAULT. A second, optional gate
|
|
38
|
+
sits in front of the same two tools and answers a different question: not "does
|
|
39
|
+
this caller have a witness account" but "is this copy of the package licensed".
|
|
40
|
+
It is inert unless LICENSE_GATE_REQUIRED=1, imports nothing while off, and
|
|
41
|
+
fails CLOSED when on with no gate module installed. See licensing.py.
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from arcaeon_ledger import Ledger
|
|
50
|
+
from arcaeon_ledger import __version__ as LEDGER_VERSION
|
|
51
|
+
from arcaeon_ledger.mcp_server import TOOLS as _LEDGER_UPSTREAM
|
|
52
|
+
from arcaeon_ledger.mcp_server import handle as _ledger_handle
|
|
53
|
+
from mcp_vet import __version__ as VET_VERSION
|
|
54
|
+
from mcp_vet.server import GRADE_DESCRIPTION as _VET_GRADE_DESCRIPTION
|
|
55
|
+
from mcp_vet.server import SCAN_DESCRIPTION as _VET_SCAN_DESCRIPTION
|
|
56
|
+
from mcp_vet.server import grade_recorded as _vet_grade_recorded
|
|
57
|
+
from mcp_vet.server import scan_recorded as _vet_scan_recorded
|
|
58
|
+
from mcp_vet.server import verify_audit_ledger as _vet_verify_audit_ledger
|
|
59
|
+
|
|
60
|
+
from . import __version__
|
|
61
|
+
from . import licensing
|
|
62
|
+
from . import witness
|
|
63
|
+
from .offers import CATALOG_URL, upgrade_message
|
|
64
|
+
|
|
65
|
+
SERVER_NAME = "arcaeon"
|
|
66
|
+
|
|
67
|
+
# upstream tool name -> the name this connector advertises.
|
|
68
|
+
LEDGER_TOOLS = {
|
|
69
|
+
t["name"]: (t["name"] if t["name"].startswith("ledger_") else f"ledger_{t['name']}")
|
|
70
|
+
for t in _LEDGER_UPSTREAM
|
|
71
|
+
}
|
|
72
|
+
_LEDGER_DESC = {t["name"]: t["description"] for t in _LEDGER_UPSTREAM}
|
|
73
|
+
|
|
74
|
+
VET_TOOLS = {
|
|
75
|
+
"mcp_vet_scan": "vet_scan",
|
|
76
|
+
"mcp_vet_grade": "vet_grade",
|
|
77
|
+
"mcp_vet_audit_verify": "vet_audit_verify",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
WITNESS_TOOLS = ("witness_pin", "witness_renew")
|
|
81
|
+
PAID_TOOLS = WITNESS_TOOLS
|
|
82
|
+
STATUS_TOOL = "arcaeon_status"
|
|
83
|
+
|
|
84
|
+
ALL_TOOLS = sorted([*LEDGER_TOOLS.values(), *VET_TOOLS.values(), *WITNESS_TOOLS, STATUS_TOOL])
|
|
85
|
+
FREE_TOOLS = [n for n in ALL_TOOLS if n not in PAID_TOOLS]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- configuration (read at CALL time, never cached at import) -------------
|
|
89
|
+
# A cached path is a path that ignores the client's environment on the second
|
|
90
|
+
# call, and clients do change it between sessions.
|
|
91
|
+
|
|
92
|
+
def ledger_path() -> Path:
|
|
93
|
+
return Path(os.environ.get("ARCAEON_LEDGER_LOG", "agent.log.jsonl"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ns_dir() -> Path:
|
|
97
|
+
"""Where the per-namespace agent ledgers live. Same default as upstream:
|
|
98
|
+
`ledgers/` beside the main log."""
|
|
99
|
+
override = os.environ.get("ARCAEON_LEDGER_NS_DIR")
|
|
100
|
+
return Path(override) if override else ledger_path().resolve().parent / "ledgers"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _key() -> str | None:
|
|
104
|
+
return os.environ.get("ARCAEON_KEY", "").strip() or None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- the ledger seam -------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _ledger_call(upstream_name: str, arguments: dict) -> dict:
|
|
110
|
+
"""One tools/call through the ledger server's own handler.
|
|
111
|
+
|
|
112
|
+
Building the JSON-RPC envelope by hand looks like ceremony next to calling
|
|
113
|
+
the private helpers directly, and it is the whole point: `handle` is the
|
|
114
|
+
surface upstream tests and ships. Reaching past it into `_prove_my_conduct`
|
|
115
|
+
would mean the connector and the standalone server take different paths to
|
|
116
|
+
the same answer.
|
|
117
|
+
"""
|
|
118
|
+
resp = _ledger_handle(
|
|
119
|
+
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
120
|
+
"params": {"name": upstream_name, "arguments": arguments}},
|
|
121
|
+
Ledger(ledger_path()),
|
|
122
|
+
ns_dir=ns_dir(),
|
|
123
|
+
)
|
|
124
|
+
result = resp["result"]
|
|
125
|
+
text = "".join(c.get("text", "") for c in result.get("content", []))
|
|
126
|
+
try:
|
|
127
|
+
payload = json.loads(text) if text else {}
|
|
128
|
+
except ValueError: # pragma: no cover - upstream always emits JSON text
|
|
129
|
+
payload = {"error": text}
|
|
130
|
+
if result.get("isError"):
|
|
131
|
+
# A caller-fixable argument problem, handed back as the tool erroring
|
|
132
|
+
# with upstream's own sentence. The alternative — returning it as a
|
|
133
|
+
# normal result — is a silent green, which is exactly what these two
|
|
134
|
+
# products exist to prevent.
|
|
135
|
+
raise ValueError(payload.get("error", text or "ledger tool failed"))
|
|
136
|
+
return payload
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _server_class():
|
|
140
|
+
"""The SDK's server class under whichever name the installed version uses
|
|
141
|
+
(2.x: MCPServer; 1.x: FastMCP). Same probe mcp-vet ships."""
|
|
142
|
+
try:
|
|
143
|
+
from mcp.server.mcpserver import MCPServer # SDK >= 2.0
|
|
144
|
+
return MCPServer
|
|
145
|
+
except ImportError:
|
|
146
|
+
pass
|
|
147
|
+
try:
|
|
148
|
+
from mcp.server.fastmcp import FastMCP # SDK 1.x
|
|
149
|
+
return FastMCP
|
|
150
|
+
except ImportError as e: # pragma: no cover - the SDK is a hard dependency
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
"arcaeon needs the MCP Python SDK: pip install 'mcp>=1.0'") from e
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_server():
|
|
156
|
+
"""Build the server. Separated from serve() so tests drive it with the
|
|
157
|
+
SDK's in-process client instead of spawning a subprocess."""
|
|
158
|
+
mcp = _server_class()(
|
|
159
|
+
name=SERVER_NAME,
|
|
160
|
+
version=__version__,
|
|
161
|
+
instructions=(
|
|
162
|
+
"Arcaeon's toolbox on one connector. ledger_* keeps a tamper-evident, "
|
|
163
|
+
"hash-chained record of what an agent did and judges another agent's "
|
|
164
|
+
"exported log. vet_* statically checks MCP-server source before you "
|
|
165
|
+
"connect to it. witness_* pins your ledger head with a party you "
|
|
166
|
+
"cannot advance (the only thing that catches truncation) and is the "
|
|
167
|
+
"one paid lane — it needs ARCAEON_KEY; everything else is free and "
|
|
168
|
+
"needs nothing. Call arcaeon_status for versions and the free/paid "
|
|
169
|
+
"split. None of these tools claim your records are TRUE: they prove "
|
|
170
|
+
"a record was not altered, which is a different and smaller thing."
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# --- ledger, re-exported whole ---------------------------------------
|
|
175
|
+
|
|
176
|
+
@mcp.tool(name=LEDGER_TOOLS["ledger_append"], description=_LEDGER_DESC["ledger_append"])
|
|
177
|
+
def ledger_append(record: dict) -> dict:
|
|
178
|
+
return _ledger_call("ledger_append", {"record": record})
|
|
179
|
+
|
|
180
|
+
@mcp.tool(name=LEDGER_TOOLS["ledger_verify"], description=_LEDGER_DESC["ledger_verify"])
|
|
181
|
+
def ledger_verify(strict: bool = False) -> dict:
|
|
182
|
+
return _ledger_call("ledger_verify", {"strict": strict})
|
|
183
|
+
|
|
184
|
+
@mcp.tool(name=LEDGER_TOOLS["prove_my_conduct"], description=_LEDGER_DESC["prove_my_conduct"])
|
|
185
|
+
def ledger_prove_my_conduct(namespace: str, events: list[str]) -> dict:
|
|
186
|
+
return _ledger_call("prove_my_conduct", {"namespace": namespace, "events": events})
|
|
187
|
+
|
|
188
|
+
@mcp.tool(name=LEDGER_TOOLS["verify_peer_ledger"],
|
|
189
|
+
description=_LEDGER_DESC["verify_peer_ledger"])
|
|
190
|
+
def ledger_verify_peer_ledger(jsonl_text: str, strict: bool = False) -> dict:
|
|
191
|
+
return _ledger_call("verify_peer_ledger",
|
|
192
|
+
{"jsonl_text": jsonl_text, "strict": strict})
|
|
193
|
+
|
|
194
|
+
@mcp.tool(name=LEDGER_TOOLS["declare_break"], description=_LEDGER_DESC["declare_break"])
|
|
195
|
+
def ledger_declare_break(namespace: str, reason: str) -> dict:
|
|
196
|
+
return _ledger_call("declare_break", {"namespace": namespace, "reason": reason})
|
|
197
|
+
|
|
198
|
+
# --- mcp-vet ----------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
@mcp.tool(name=VET_TOOLS["mcp_vet_scan"], description=_VET_SCAN_DESCRIPTION)
|
|
201
|
+
def vet_scan(path: str) -> list[dict]:
|
|
202
|
+
return _vet_scan_recorded(path)
|
|
203
|
+
|
|
204
|
+
@mcp.tool(name=VET_TOOLS["mcp_vet_grade"], description=_VET_GRADE_DESCRIPTION)
|
|
205
|
+
def vet_grade(path: str) -> dict:
|
|
206
|
+
return _vet_grade_recorded(path)
|
|
207
|
+
|
|
208
|
+
@mcp.tool(
|
|
209
|
+
name=VET_TOOLS["mcp_vet_audit_verify"],
|
|
210
|
+
description=(
|
|
211
|
+
"Recompute the hash chain over mcp-vet's OWN call-record ledger "
|
|
212
|
+
"(one tamper-evident row per vet_scan / vet_grade call it made) and "
|
|
213
|
+
"report whether it is intact: ok (true / false / null when the scan "
|
|
214
|
+
"was bounded - not a green), rows, breaks, and first_break naming "
|
|
215
|
+
"the first line that does not reproduce. Also reports whether the "
|
|
216
|
+
"record is switched on at all - the failure worth catching is an "
|
|
217
|
+
"audit trail the caller wrongly believes exists. Reads only; never "
|
|
218
|
+
"writes to the ledger it is verifying, and never records itself."),
|
|
219
|
+
)
|
|
220
|
+
def vet_audit_verify(path: str | None = None) -> dict:
|
|
221
|
+
return _vet_verify_audit_ledger(path)
|
|
222
|
+
|
|
223
|
+
# --- the paid lane ----------------------------------------------------
|
|
224
|
+
# No return annotation on purpose: the gated answer is a plain sentence and
|
|
225
|
+
# the paid answer is the witness's JSON. Pinning one output schema over both
|
|
226
|
+
# would force the refusal to wear a shape it does not have.
|
|
227
|
+
|
|
228
|
+
@mcp.tool(
|
|
229
|
+
name="witness_pin",
|
|
230
|
+
description=(
|
|
231
|
+
"PAID (needs ARCAEON_KEY). Record your ledger head (rows + chain) with "
|
|
232
|
+
"the hosted witness, a party you cannot advance — the only thing that "
|
|
233
|
+
"catches TRUNCATION, which a hash chain alone cannot. Returns the "
|
|
234
|
+
"stored pin, the public commit, and the history URL. Without a key it "
|
|
235
|
+
"returns a plain note with the free tier and the $5 pack; it never "
|
|
236
|
+
"silently does nothing. Proves no-truncation only relative to what the "
|
|
237
|
+
"witness saw and only as recently as the last pin: the pin gap IS the "
|
|
238
|
+
"security parameter."),
|
|
239
|
+
)
|
|
240
|
+
def witness_pin(namespace: str, rows: int, chain: str):
|
|
241
|
+
# License first, account second. "May this copy run at all" is a
|
|
242
|
+
# different question from "does this caller have a witness account",
|
|
243
|
+
# and answering them in the other order would sell a pack to someone
|
|
244
|
+
# whose copy we are about to refuse anyway. Off unless asked for.
|
|
245
|
+
refused = licensing.refusal_for("witness_pin", namespace)
|
|
246
|
+
if refused:
|
|
247
|
+
return refused
|
|
248
|
+
key = _key()
|
|
249
|
+
if not key:
|
|
250
|
+
return upgrade_message("witness_pin", FREE_TOOLS)
|
|
251
|
+
return witness.pin(namespace, rows, chain, key)
|
|
252
|
+
|
|
253
|
+
@mcp.tool(
|
|
254
|
+
name="witness_renew",
|
|
255
|
+
description=(
|
|
256
|
+
"PAID (needs ARCAEON_KEY). Restate an UNCHANGED head so a finished log "
|
|
257
|
+
"stops looking abandoned: rows and chain must match the current head "
|
|
258
|
+
"exactly (a mismatch is refused, 409 renewal_head_mismatch) — a renewal "
|
|
259
|
+
"moves the cadence deadline and can never launder a re-mint or erase a "
|
|
260
|
+
"deadline that was already missed. Without a key it returns a plain "
|
|
261
|
+
"note with the free tier and the $5 pack."),
|
|
262
|
+
)
|
|
263
|
+
def witness_renew(namespace: str, rows: int, chain: str):
|
|
264
|
+
refused = licensing.refusal_for("witness_renew", namespace)
|
|
265
|
+
if refused:
|
|
266
|
+
return refused
|
|
267
|
+
key = _key()
|
|
268
|
+
if not key:
|
|
269
|
+
return upgrade_message("witness_renew", FREE_TOOLS)
|
|
270
|
+
return witness.renew(namespace, rows, chain, key)
|
|
271
|
+
|
|
272
|
+
# --- what is in here, and what costs money ----------------------------
|
|
273
|
+
|
|
274
|
+
@mcp.tool(
|
|
275
|
+
name=STATUS_TOOL,
|
|
276
|
+
description=(
|
|
277
|
+
"What this connector is made of and what any of it costs: the version "
|
|
278
|
+
"of each bundled package, every tool split into free and paid, whether "
|
|
279
|
+
"an ARCAEON_KEY is currently set, and where the ledger it writes to "
|
|
280
|
+
"lives. Call this first if a tool refused you."),
|
|
281
|
+
)
|
|
282
|
+
def arcaeon_status() -> dict:
|
|
283
|
+
return {
|
|
284
|
+
"connector_version": __version__,
|
|
285
|
+
"components": {
|
|
286
|
+
"arcaeon-ledger": LEDGER_VERSION,
|
|
287
|
+
"mcp-vet": VET_VERSION,
|
|
288
|
+
"arcaeon-connector": __version__,
|
|
289
|
+
},
|
|
290
|
+
"free_tools": list(FREE_TOOLS),
|
|
291
|
+
"paid_tools": list(PAID_TOOLS),
|
|
292
|
+
"key_present": _key() is not None,
|
|
293
|
+
"key_env_var": "ARCAEON_KEY",
|
|
294
|
+
"license_gate": licensing.status(),
|
|
295
|
+
"witness_endpoint": witness.base_url(),
|
|
296
|
+
"ledger_path": str(ledger_path()),
|
|
297
|
+
"ns_dir": str(ns_dir()),
|
|
298
|
+
"price_list": CATALOG_URL,
|
|
299
|
+
"notes": [
|
|
300
|
+
"Everything except witness_* is free forever and needs no key; "
|
|
301
|
+
"the witness LIBRARY is self-hostable free too, the paid part is "
|
|
302
|
+
"us hosting it.",
|
|
303
|
+
"The witness free tier is 100 pins/month, no card — email "
|
|
304
|
+
"hello@arcaeon.io for a key.",
|
|
305
|
+
"Auth is bearer-key only (auth_level bearer-stage0): a leaked key "
|
|
306
|
+
"can pin and renew in your name. Owner-signature auth is designed, "
|
|
307
|
+
"not built.",
|
|
308
|
+
"Tamper-evidence is not truth: these tools prove a record was not "
|
|
309
|
+
"altered, never that what it records was correct.",
|
|
310
|
+
],
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return mcp
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def serve() -> None:
|
|
317
|
+
"""Run over stdio. Local pipe only — the tools read caller-named paths, so
|
|
318
|
+
do not put this behind a network transport without auth in front (which is
|
|
319
|
+
what vet_scan's own zero-auth check would tell you)."""
|
|
320
|
+
build_server().run(transport="stdio")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__": # pragma: no cover
|
|
324
|
+
serve()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The hosted witness's write endpoints, wrapped thin.
|
|
2
|
+
|
|
3
|
+
`POST /api/pin` and `POST /api/renew` are bearer-key HTTP. There is no Python
|
|
4
|
+
client to import, so this is the one place in the connector that speaks a
|
|
5
|
+
protocol rather than re-exporting a package — kept to stdlib urllib, kept to
|
|
6
|
+
one function, and kept honest about what it returns.
|
|
7
|
+
|
|
8
|
+
Failure is DATA here, not an exception: a 401, a 409 monotonic rejection, a 429
|
|
9
|
+
over-cap, an unreachable host — every one of them comes back as a dict with the
|
|
10
|
+
status and the server's own reason in it. A tool that raises on a 429 hands the
|
|
11
|
+
agent a stack trace where the agent needed the sentence "you are out of pins".
|
|
12
|
+
|
|
13
|
+
AUTH HONESTY, carried through from the witness's own README: this is bearer-key
|
|
14
|
+
auth (`auth_level: "bearer-stage0"`), not owner-signature auth. A leaked key can
|
|
15
|
+
pin and can renew in your name. Owner-signature auth is designed (STAGE1) and
|
|
16
|
+
not built. Treat ARCAEON_KEY like a password, not like an identity.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
|
|
25
|
+
from .offers import WITNESS_ENDPOINT
|
|
26
|
+
|
|
27
|
+
DEFAULT_TIMEOUT = 20.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def base_url() -> str:
|
|
31
|
+
"""The witness to talk to. Overridable so a self-hoster (the library is free
|
|
32
|
+
forever) can point the same tools at their own deployment."""
|
|
33
|
+
return os.environ.get("ARCAEON_WITNESS_URL", WITNESS_ENDPOINT).rstrip("/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _http_post(url: str, body: dict, key: str, timeout: float = DEFAULT_TIMEOUT):
|
|
37
|
+
"""POST JSON with a bearer key. Returns (status, payload).
|
|
38
|
+
|
|
39
|
+
Never raises for an HTTP-level failure: status 0 means the request did not
|
|
40
|
+
complete at all, and the payload says why in words. Tests stub exactly this
|
|
41
|
+
function, so the seam between "our gate let it through" and "the network
|
|
42
|
+
happened" is one named thing.
|
|
43
|
+
"""
|
|
44
|
+
req = urllib.request.Request(
|
|
45
|
+
url,
|
|
46
|
+
data=json.dumps(body).encode("utf-8"),
|
|
47
|
+
headers={
|
|
48
|
+
"Authorization": f"Bearer {key}",
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
"User-Agent": "arcaeon-connector",
|
|
51
|
+
},
|
|
52
|
+
method="POST",
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
56
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
57
|
+
return resp.status, _json_or_text(raw)
|
|
58
|
+
except urllib.error.HTTPError as e:
|
|
59
|
+
raw = e.read().decode("utf-8", "replace") if e.fp else ""
|
|
60
|
+
return e.code, _json_or_text(raw)
|
|
61
|
+
except urllib.error.URLError as e:
|
|
62
|
+
return 0, {"error": f"witness unreachable: {e.reason}"}
|
|
63
|
+
except OSError as e:
|
|
64
|
+
return 0, {"error": f"witness unreachable: {e}"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _json_or_text(raw: str) -> dict:
|
|
68
|
+
if not raw.strip():
|
|
69
|
+
return {}
|
|
70
|
+
try:
|
|
71
|
+
parsed = json.loads(raw)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return {"error": raw[:800]}
|
|
74
|
+
return parsed if isinstance(parsed, dict) else {"result": parsed}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write(path: str, namespace: str, rows: int, chain: str, key: str) -> dict:
|
|
78
|
+
status, payload = _http_post(
|
|
79
|
+
f"{base_url()}{path}", {"namespace": namespace, "rows": rows, "chain": chain}, key)
|
|
80
|
+
out = {"ok": status in (200, 201), "status": status, "endpoint": base_url() + path}
|
|
81
|
+
out.update(payload if isinstance(payload, dict) else {"result": payload})
|
|
82
|
+
if not out["ok"] and "error" not in out:
|
|
83
|
+
# A non-2xx with no error text is still a refusal; say so rather than
|
|
84
|
+
# letting `ok:false` sit next to a body that reads like a success.
|
|
85
|
+
out["error"] = f"witness returned {status}"
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pin(namespace: str, rows: int, chain: str, key: str) -> dict:
|
|
90
|
+
"""Record this ledger head with the witness. 201 on a new pin."""
|
|
91
|
+
return _write("/api/pin", namespace, rows, chain, key)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def renew(namespace: str, rows: int, chain: str, key: str) -> dict:
|
|
95
|
+
"""Restate an unchanged head so a finished log stops looking abandoned.
|
|
96
|
+
`rows`/`chain` must match the current head exactly — the witness rejects a
|
|
97
|
+
renewal that tries to advance one (409 renewal_head_mismatch)."""
|
|
98
|
+
return _write("/api/renew", namespace, rows, chain, key)
|