csoai-gspc-mcp 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # csoai-gspc-mcp
2
2
 
3
+ [![22 axes measured · 14 model fleets · 3 public leader scores · 8 fact runs · TIE is TIE · not a certificate. Three states only: VALID · INVALID · UNCHECKABLE.](https://councilof.ai/badge/gspc.svg)](https://councilof.ai/gspc-scoreboard)
4
+
3
5
  Stdio MCP server for the live GSPC board and the signed measurement cards at
4
6
  [councilof.ai](https://councilof.ai). Zero dependencies. Node >= 20.
5
7
 
@@ -18,32 +20,67 @@ are reported as two labelled numbers and never reconciled.
18
20
  | `get_axis` | One axis row from the live board: `n`, `accuracy`, `interval`, MEASURED/UNMEASURED status, dates. Args: `{ "axis": "jail" }`. |
19
21
  | `verify_card` | Verify a signed `gspc.measurement-card` under the published rule — recompute the id from the canonical body, check the Ed25519 signature under the **pinned** key `did:web:csoai.org#card-attestation-1`. A card signed with its own freshly-made key is INVALID, not valid. Args: `{ "card": <object | JSON string | councilof.ai URL> }`. |
20
22
  | `list_cards` | What the published index (`/signed/card_index.json`) declares next to what the card store endpoint (`/api/cards`) reports — two labelled numbers, never reconciled. Optional `axis`, `limit`. |
23
+ | `get_root` | GET `https://councilof.ai/root.json`. Three states: VALID / UNREACHABLE / UNCHECKABLE. Separate from GSPC. Never a certificate. |
24
+ | `get_card` | GET one card-v0 leaf by sha256. VALID / INVALID (not a leaf) / UNCHECKABLE (fetch failed). A 404 leaf is INVALID, not UNCHECKABLE. |
25
+ | `verify_inclusion` | GET `/api/proof?sha=`. VALID (included) / INVALID (not a leaf) / UNCHECKABLE (proof endpoint unreachable). |
26
+
27
+ Seven free tools above; five metered ones below. `tools/list` returns all twelve, and
28
+ `wired-tools.test.mjs` fails if a listed tool does not run or a running tool is not listed.
21
29
 
22
- The same four tools, from the same definitions file
30
+ The same seven free tools, from the same definitions file
23
31
  (`functions/mcp/gspc-tools.json`), are served over HTTP at
24
32
  `https://councilof.ai/mcp` (streamable HTTP, JSON-RPC 2.0 POST). Use whichever
25
33
  transport your client speaks; the contracts are identical.
26
34
 
35
+ ### The five x402-metered tools — carried here since 0.2.0
36
+
37
+ | tool | route | free path |
38
+ |---|---|---|
39
+ | `commission_card` | `/api/request-attestation` | — (a payment never mints a MEASURED cell) |
40
+ | `art50_marking_evidence` | `/api/art50/marking-evidence` | `preview: true` |
41
+ | `rwa_evidence` | `/api/rwa/evidence` | `preview: true` (unsigned state) |
42
+ | `witness_hash` | `/api/witness` | — |
43
+ | `receipts_batch` | `/api/receipts/batch` | `preview: true` (count, span, roots, batch sha256) |
44
+
45
+ Payment travels as the **`x_payment` argument**, not as a transport header — so stdio carries these
46
+ exactly as the HTTP door does. Up to 0.1.1 this README said the opposite ("stdio has no payment header to
47
+ forward"); that was a statement about the transport, and it was wrong about the mechanism. The server
48
+ forwards your `x_payment` verbatim as the `X-PAYMENT` header on one request to `councilof.ai` and never
49
+ inspects, signs or invents a receipt. Settlement is the route's job, fail-closed.
50
+
51
+ Four honest statuses, and no fifth:
52
+
53
+ - **`PAYMENT_REQUIRED`** — the route answered 402. The full challenge (`accepts[]`, the `PAYMENT-REQUIRED`
54
+ header) comes back as `structuredContent`. Nothing was charged. A challenge is an answer, not a failure.
55
+ - **`DELIVERED`** — the route answered 2xx, with the settle echo when the route sent one.
56
+ - **`NOT_DEPLOYED`** — the route answered 404 on this origin. Said plainly, never a fabricated result.
57
+ - **`UNREACHABLE`** / **`BAD_ARGUMENTS`** — the call could not be made. Nothing was charged.
58
+
59
+ **Known limitation, stated rather than hidden (2026-09-04):** the free `preview` paths and the 402
60
+ challenge work today, but **settlement on the live rail is failing** — a genuine signed EIP-3009
61
+ authorization is rejected by the facilitator with HTTP 400 after the buyer signs, because the facilitator
62
+ now advertises two x402 dialects for Base and the wrong one is being selected. The fix is written and
63
+ tested but not merged. Until it is, treat the paid paths as: challenge yes, delivery no. This server
64
+ reports what the route actually said and never converts a failed settlement into a result.
65
+
66
+ Every paid deliverable is measurement, not certification; no tool on either transport carries a trust
67
+ label; amounts appear only inside a 402 challenge.
68
+
27
69
  ## Install
28
70
 
29
- This package is **not yet published to npm** (checked 2026-08-27). Until it is,
30
- install from a checkout of the repo — the server is a single file with no
31
- dependencies, so "install" is just pointing your client at it:
71
+ Published on npm as [`csoai-gspc-mcp`](https://www.npmjs.com/package/csoai-gspc-mcp). No checkout required:
32
72
 
33
73
  ```sh
34
- git clone https://github.com/CSOAI-ORG/councilof-ai.git
35
- # the server is at councilof-ai/mcp/gspc-server/index.mjs — no npm install needed
74
+ npx -y csoai-gspc-mcp
36
75
  ```
37
76
 
38
- Below, replace `/ABS/PATH/TO/councilof-ai` with where you cloned it.
39
-
40
77
  ### Claude Code
41
78
 
42
79
  ```sh
43
- claude mcp add gspc -- node /ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs
80
+ claude mcp add gspc -- npx -y csoai-gspc-mcp
44
81
  ```
45
82
 
46
- Once published to npm this becomes `claude mcp add gspc -- npx -y csoai-gspc-mcp`.
83
+ From a checkout of the repo the server is `mcp/gspc-server/index.mjs` (no extra install).
47
84
 
48
85
  ### Claude Desktop
49
86
 
@@ -55,8 +92,8 @@ Add to `claude_desktop_config.json` (macOS:
55
92
  {
56
93
  "mcpServers": {
57
94
  "gspc": {
58
- "command": "node",
59
- "args": ["/ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs"]
95
+ "command": "npx",
96
+ "args": ["-y", "csoai-gspc-mcp"]
60
97
  }
61
98
  }
62
99
  }
@@ -70,16 +107,24 @@ Add to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` globally):
70
107
  {
71
108
  "mcpServers": {
72
109
  "gspc": {
73
- "command": "node",
74
- "args": ["/ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs"]
110
+ "command": "npx",
111
+ "args": ["-y", "csoai-gspc-mcp"]
75
112
  }
76
113
  }
77
114
  }
78
115
  ```
79
116
 
117
+ ### Grok Build
118
+
119
+ ```toml
120
+ [mcp_servers.gspc-npm]
121
+ command = "npx"
122
+ args = ["-y", "csoai-gspc-mcp"]
123
+ ```
124
+
80
125
  ### Any other stdio MCP client (Grok Bot, DSH harness, your own agent)
81
126
 
82
- Spawn `node /ABS/PATH/TO/councilof-ai/mcp/gspc-server/index.mjs` and speak
127
+ Spawn `npx -y csoai-gspc-mcp` and speak
83
128
  newline-delimited JSON-RPC 2.0 on its stdin/stdout (stderr is logs only):
84
129
 
85
130
  1. send `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"you","version":"0"}}}`
package/gspc-tools.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema": "csoai.gspc-mcp-tools/1",
3
- "note": "ONE source of tool definitions for the GSPC MCP tools, shared by the stdio server (mcp/gspc-server/index.mjs) and the HTTP endpoint (functions/mcp/[[path]].ts) so the two surfaces cannot drift. Display text uses 'axis', never the plural — owner language ruling; code identifiers are exempt.",
3
+ "note": "ONE source of tool definitions for the GSPC MCP tools, shared by the stdio server (mcp/gspc-server/index.mjs) and the HTTP endpoint (functions/mcp/[[path]].ts) so the two surfaces cannot drift. Display text uses 'axis', never the plural \u2014 owner language ruling; code identifiers are exempt.",
4
4
  "tools": [
5
5
  {
6
6
  "name": "board_totals",
7
- "description": "Live GSPC board totals from https://councilof.ai/api/gspc. Returns the slot count and the measured count as two labelled numbers WITH their kind — a slot is a declared position on the board, a measurement is a real run behind it; the two are never summed and never swapped — plus as_of dates for the board and for this fetch. We measure, never certify. If the board cannot be fetched the answer is a distinct UNREACHABLE state: no cached number is ever presented as live.",
7
+ "description": "Live GSPC board totals from https://councilof.ai/api/gspc. Returns the slot count and the measured count as two labelled numbers WITH their kind \u2014 a slot is a declared position on the board, a measurement is a real run behind it; the two are never summed and never swapped \u2014 plus as_of dates for the board and for this fetch. We measure, never certify. If the board cannot be fetched the answer is a distinct UNREACHABLE state: no cached number is ever presented as live.",
8
8
  "inputSchema": {
9
9
  "type": "object",
10
10
  "properties": {},
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "name": "get_axis",
16
- "description": "One axis row from the live GSPC board at https://councilof.ai/api/gspc: n, accuracy, interval, MEASURED or UNMEASURED status, family, and dates. An unmeasured axis is a first-class answer — a declared slot with no run behind it, published so the gap is visible — never an error and never a zero. Never a certification.",
16
+ "description": "One axis row from the live GSPC board at https://councilof.ai/api/gspc: n, accuracy, interval, MEASURED or UNMEASURED status, family, and dates. An unmeasured axis is a first-class answer \u2014 a declared slot with no run behind it, published so the gap is visible \u2014 never an error and never a zero. Never a certification.",
17
17
  "inputSchema": {
18
18
  "type": "object",
19
19
  "properties": {
@@ -22,28 +22,39 @@
22
22
  "description": "The axis name as it appears on the board, e.g. governance, safety, jail, provenance-controls. Case-insensitive. An unknown name returns the list of names the board actually carries."
23
23
  }
24
24
  },
25
- "required": ["axis"],
25
+ "required": [
26
+ "axis"
27
+ ],
26
28
  "additionalProperties": false
27
29
  }
28
30
  },
29
31
  {
30
32
  "name": "verify_card",
31
- "description": "Verify a signed gspc.measurement-card under the published rule (https://councilof.ai/signed/HOW-TO-VERIFY.md): recompute the id from the canonical body bytes, then check the Ed25519 signature under the PINNED key published at did:web:csoai.org#card-attestation-1. A card that carries its own key proves only that the file is self-consistent — anyone can alter a body and sign it with a key they just generated — so a signer other than the published key is reported INVALID. Three verdicts, never two: VALID, INVALID (with the reason), or UNCHECKABLE when the check could not be completed — 'could not check' is a different claim from 'forged'. Accepts the card as a JSON object, a JSON string, or a councilof.ai / csoai.org URL. Never a certification.",
33
+ "description": "Verify a signed gspc.measurement-card under the published rule (https://councilof.ai/signed/HOW-TO-VERIFY.md): recompute the id from the canonical body bytes, then check the Ed25519 signature under the PINNED key published at did:web:csoai.org#card-attestation-1. A card that carries its own key proves only that the file is self-consistent \u2014 anyone can alter a body and sign it with a key they just generated \u2014 so a signer other than the published key is reported INVALID. Three verdicts, never two: VALID, INVALID (with the reason), or UNCHECKABLE when the check could not be completed \u2014 'could not check' is a different claim from 'forged'. Accepts the card as a JSON object, a JSON string, or a councilof.ai / csoai.org URL. Never a certification.",
32
34
  "inputSchema": {
33
35
  "type": "object",
34
36
  "properties": {
35
37
  "card": {
36
38
  "description": "The signed card: an object, a JSON string, or a councilof.ai / csoai.org URL to one.",
37
- "anyOf": [{ "type": "object" }, { "type": "string" }]
39
+ "anyOf": [
40
+ {
41
+ "type": "object"
42
+ },
43
+ {
44
+ "type": "string"
45
+ }
46
+ ]
38
47
  }
39
48
  },
40
- "required": ["card"],
49
+ "required": [
50
+ "card"
51
+ ],
41
52
  "additionalProperties": false
42
53
  }
43
54
  },
44
55
  {
45
56
  "name": "list_cards",
46
- "description": "The published signed-card index (https://councilof.ai/signed/card_index.json): what the index declares (n_cards) and how many rows it actually carries, reported next to — never reconciled with — the count the card store endpoint (https://councilof.ai/api/cards) reports for itself. Two labelled numbers from two surfaces; if they disagree, this tool shows the disagreement rather than picking one. Optional filters return recent rows: axis, limit.",
57
+ "description": "The published signed-card index (https://councilof.ai/signed/card_index.json): what the index declares (n_cards) and how many rows it actually carries, reported next to \u2014 never reconciled with \u2014 the count the card store endpoint (https://councilof.ai/api/cards) reports for itself. Two labelled numbers from two surfaces; if they disagree, this tool shows the disagreement rather than picking one. Optional filters return recent rows: axis, limit.",
47
58
  "inputSchema": {
48
59
  "type": "object",
49
60
  "properties": {
@@ -60,6 +71,49 @@
60
71
  },
61
72
  "additionalProperties": false
62
73
  }
74
+ },
75
+ {
76
+ "name": "get_root",
77
+ "description": "GET the permissionless public-root at https://councilof.ai/root.json. Returns merkle_root, card_count, as_of, and UNMEASURED notes. Separate from GSPC. Three states: VALID (fetched), UNREACHABLE (could not fetch), UNCHECKABLE (body unreadable). Never a certificate.",
78
+ "inputSchema": {
79
+ "type": "object",
80
+ "properties": {},
81
+ "additionalProperties": false
82
+ }
83
+ },
84
+ {
85
+ "name": "get_card",
86
+ "description": "GET one public-root card-v0 leaf by sha256 (64 hex) from https://councilof.ai/cards/{sha16}.json. UNMEASURED cells stay visible. Three states: VALID (fetched), INVALID (not a leaf of the live root), UNCHECKABLE (fetch failed). Not a GSPC measurement-card.",
87
+ "inputSchema": {
88
+ "type": "object",
89
+ "properties": {
90
+ "sha256": {
91
+ "type": "string",
92
+ "description": "64-char hex payload SHA-256 of the card-v0 leaf."
93
+ }
94
+ },
95
+ "required": [
96
+ "sha256"
97
+ ],
98
+ "additionalProperties": false
99
+ }
100
+ },
101
+ {
102
+ "name": "verify_inclusion",
103
+ "description": "Check a sha256 against the live public-root merkle via GET /api/proof?sha=. Three states only: VALID (included), INVALID (not a leaf), UNCHECKABLE (proof endpoint unreachable). Does not claim Ed25519 unless sig_ed25519 is present and checked separately. Never a grade.",
104
+ "inputSchema": {
105
+ "type": "object",
106
+ "properties": {
107
+ "sha256": {
108
+ "type": "string",
109
+ "description": "64-char hex digest to test for inclusion."
110
+ }
111
+ },
112
+ "required": [
113
+ "sha256"
114
+ ],
115
+ "additionalProperties": false
116
+ }
63
117
  }
64
118
  ]
65
- }
119
+ }
package/index.mjs CHANGED
@@ -18,8 +18,9 @@
18
18
  * numbers. This server never reconciles them.
19
19
  *
20
20
  * ONE SOURCE OF TRUTH:
21
- * - Tool definitions come from gspc-tools.json — the same file the HTTP
22
- * endpoint at councilof.ai/mcp imports (functions/mcp/gspc-tools.json).
21
+ * - Tool definitions come from gspc-tools.json (free) and paid-tools.json
22
+ * (x402-metered) — the same two files the HTTP endpoint at councilof.ai/mcp
23
+ * imports (functions/mcp/*.json).
23
24
  * In a repo checkout that file is read directly; the npm package ships a
24
25
  * byte-identical copy made at pack time (npm run prepack).
25
26
  * - verify_card runs the code in public/signed/verify-card.mjs — the same
@@ -31,7 +32,7 @@ import { createInterface } from "node:readline";
31
32
  import { readFileSync, existsSync } from "node:fs";
32
33
  import { fileURLToPath } from "node:url";
33
34
 
34
- const VERSION = "0.1.0";
35
+ const VERSION = "0.2.0";
35
36
  const ORIGIN = process.env.GSPC_ORIGIN || "https://councilof.ai";
36
37
  const FETCH_TIMEOUT_MS = 15000;
37
38
 
@@ -56,7 +57,24 @@ if (!TOOLS_PATH) {
56
57
  process.stderr.write("csoai-gspc-mcp: gspc-tools.json not found — broken install\n");
57
58
  process.exit(1);
58
59
  }
59
- const TOOLS = JSON.parse(readFileSync(TOOLS_PATH, "utf8")).tools;
60
+ const FREE_TOOLS = JSON.parse(readFileSync(TOOLS_PATH, "utf8")).tools;
61
+
62
+ /**
63
+ * The x402-metered tools, from the same definitions file the HTTP door uses.
64
+ *
65
+ * Payment travels as the `x_payment` TOOL ARGUMENT, not as a transport header, so stdio carries
66
+ * these exactly as the HTTP door does: the argument is forwarded verbatim as the X-PAYMENT header
67
+ * on one same-origin request, and this server never inspects, signs or invents a receipt.
68
+ * Settlement is the route's job, fail-closed. Without `x_payment` the tool returns the route's own
69
+ * 402 challenge, which is an answer and not a failure — and `preview` is free where a tool offers it.
70
+ */
71
+ const PAID_TOOLS_PATH = firstExisting([
72
+ "../../functions/mcp/paid-tools.json", // repo checkout: the canonical file
73
+ "./paid-tools.json", // npm package: the byte-identical pack-time copy
74
+ ]);
75
+ const PAID_TOOLS = PAID_TOOLS_PATH ? JSON.parse(readFileSync(PAID_TOOLS_PATH, "utf8")).tools : [];
76
+ const PAID_BY_NAME = new Map(PAID_TOOLS.map((t) => [t.name, t]));
77
+ const TOOLS = [...FREE_TOOLS, ...PAID_TOOLS];
60
78
 
61
79
  const VERIFIER_PATH = firstExisting([
62
80
  "../../public/signed/verify-card.mjs", // repo checkout: the canonical file
@@ -76,7 +94,11 @@ async function fetchJson(path) {
76
94
  headers: { accept: "application/json" },
77
95
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
78
96
  });
79
- if (!r.ok) throw new Error(`GET ${url} returned HTTP ${r.status}`);
97
+ if (!r.ok) {
98
+ const err = new Error(`GET ${url} returned HTTP ${r.status}`);
99
+ err.status = r.status;
100
+ throw err;
101
+ }
80
102
  return r.json();
81
103
  }
82
104
 
@@ -271,11 +293,211 @@ async function listCards(args) {
271
293
  return out;
272
294
  }
273
295
 
296
+ async function getRoot() {
297
+ try {
298
+ const d = await fetchJson("/root.json");
299
+ return {
300
+ state: "VALID",
301
+ source: `${ORIGIN}/root.json`,
302
+ kind: d.kind ?? null,
303
+ as_of: d.as_of ?? null,
304
+ card_count: d.card_count ?? null,
305
+ merkle_root: d.merkle_root ?? null,
306
+ note: d.note ?? null,
307
+ not_a_certification: true,
308
+ not_gspc: true,
309
+ };
310
+ } catch (e) {
311
+ return { ...unreachable("/root.json", e), state: "UNREACHABLE", not_gspc: true };
312
+ }
313
+ }
314
+
315
+ async function getCard(args) {
316
+ const sha = String(args.sha256 || "").trim().toLowerCase();
317
+ if (!/^[0-9a-f]{64}$/.test(sha)) {
318
+ return { state: "UNCHECKABLE", reason: "sha256 must be 64 hex", not_a_certification: true };
319
+ }
320
+ try {
321
+ const d = await fetchJson(`/cards/${sha.slice(0, 16)}.json`);
322
+ const card = d.card || d;
323
+ const match = String(card.sha256 || "") === sha;
324
+ return {
325
+ state: match ? "VALID" : "INVALID",
326
+ sha256: sha,
327
+ source: `${ORIGIN}/cards/${sha.slice(0, 16)}.json`,
328
+ surface: card.surface ?? null,
329
+ unmeasured: card.unmeasured ?? [],
330
+ sig_ed25519: card.sig_ed25519 ?? null,
331
+ not_a_certification: true,
332
+ not_gspc: true,
333
+ };
334
+ } catch (e) {
335
+ if (e && e.status === 404) {
336
+ return {
337
+ state: "INVALID",
338
+ sha256: sha,
339
+ reason: "not a leaf of the live root",
340
+ source: `${ORIGIN}/cards/${sha.slice(0, 16)}.json`,
341
+ not_a_certification: true,
342
+ not_gspc: true,
343
+ };
344
+ }
345
+ return { ...unreachable(`/cards/${sha.slice(0, 16)}.json`, e), state: "UNCHECKABLE", sha256: sha };
346
+ }
347
+ }
348
+
349
+ async function verifyInclusion(args) {
350
+ const sha = String(args.sha256 || "").trim().toLowerCase();
351
+ if (!/^[0-9a-f]{64}$/.test(sha)) {
352
+ return { state: "UNCHECKABLE", reason: "sha256 must be 64 hex", not_a_certification: true };
353
+ }
354
+ try {
355
+ const d = await fetchJson(`/api/proof?sha=${sha}`);
356
+ if (d.kind === "inclusion") {
357
+ return { state: "VALID", sha256: sha, merkle_root: d.merkle_root ?? null, not_a_certification: true };
358
+ }
359
+ if (d.error === "not_found") {
360
+ return { state: "INVALID", sha256: sha, reason: d.reason ?? "not a leaf", not_a_certification: true };
361
+ }
362
+ return { state: "UNCHECKABLE", sha256: sha, reason: d.reason ?? "unexpected proof body", not_a_certification: true };
363
+ } catch (e) {
364
+ if (e && e.status === 404) {
365
+ return { state: "INVALID", sha256: sha, reason: "not a leaf", not_a_certification: true };
366
+ }
367
+ return { ...unreachable(`/api/proof?sha=${sha}`, e), state: "UNCHECKABLE", sha256: sha };
368
+ }
369
+ }
370
+
371
+ /* ---------------------------------------------------------------- paid tools */
372
+
373
+ const PAID_DOCTRINE =
374
+ "measurement, not certification — no tool here carries or awards a trust label of any kind; " +
375
+ "verification stays free";
376
+
377
+ /** Build the one request a paid tool makes. A caller cannot steer it to another URL. */
378
+ function buildPaidRequest(name, args) {
379
+ const tool = PAID_BY_NAME.get(name);
380
+ if (!tool) return { error: `unknown paid tool: ${name}` };
381
+ const route = tool.csoai.route;
382
+ const str = (k) => (typeof args[k] === "string" ? args[k].trim() : "");
383
+ const flag = (k) => args[k] === true || args[k] === "1" || args[k] === "true";
384
+ const headers = { accept: "application/json" };
385
+ const xp = str("x_payment");
386
+ if (xp) headers["x-payment"] = xp;
387
+ const u = new URL(route, ORIGIN);
388
+ let method = "GET";
389
+ let body;
390
+
391
+ switch (name) {
392
+ case "commission_card":
393
+ if (!str("subject")) return { error: "subject is required" };
394
+ u.searchParams.set("subject", str("subject"));
395
+ if (str("axis")) u.searchParams.set("axis", str("axis"));
396
+ break;
397
+ case "art50_marking_evidence":
398
+ if (flag("preview")) u.searchParams.set("preview", "1");
399
+ if (str("bytes_b64") || str("manifest_b64")) {
400
+ method = "POST";
401
+ headers["content-type"] = "application/json";
402
+ body = JSON.stringify(
403
+ str("bytes_b64") ? { bytes_b64: str("bytes_b64") } : { manifest_b64: str("manifest_b64") },
404
+ );
405
+ } else if (str("url")) {
406
+ u.searchParams.set("url", str("url"));
407
+ } else {
408
+ return { error: "one of url, bytes_b64 or manifest_b64 is required" };
409
+ }
410
+ break;
411
+ case "rwa_evidence":
412
+ if (!str("asset")) return { error: "asset is required" };
413
+ u.searchParams.set("asset", str("asset"));
414
+ if (flag("preview")) u.searchParams.set("preview", "1");
415
+ break;
416
+ case "witness_hash":
417
+ if (!str("sha256") && !str("url")) return { error: "sha256 or url is required" };
418
+ if (str("sha256")) u.searchParams.set("sha256", str("sha256").toLowerCase());
419
+ if (str("url")) u.searchParams.set("url", str("url"));
420
+ if (str("label")) u.searchParams.set("label", str("label"));
421
+ break;
422
+ case "receipts_batch":
423
+ if (!str("from")) return { error: "from is required (ISO-8601)" };
424
+ u.searchParams.set("from", str("from"));
425
+ if (str("to")) u.searchParams.set("to", str("to"));
426
+ if (flag("preview")) u.searchParams.set("preview", "1");
427
+ break;
428
+ default:
429
+ return { error: `no request builder for ${name}` };
430
+ }
431
+ return { url: u.toString(), init: { method, headers, ...(body ? { body } : {}) }, route };
432
+ }
433
+
434
+ /**
435
+ * Forward one paid tool to its route and report what came back, in the route's own words.
436
+ * 402 is PAYMENT_REQUIRED and not an error; 404 is NOT_DEPLOYED and never a fabricated result.
437
+ */
438
+ async function callPaidTool(name, args) {
439
+ const tool = PAID_BY_NAME.get(name);
440
+ const base = {
441
+ tool: name,
442
+ route: tool.csoai.route,
443
+ sku: tool.csoai.sku,
444
+ rail: tool.csoai.rail,
445
+ doctrine: PAID_DOCTRINE,
446
+ not_a_certification: true,
447
+ };
448
+ const built = buildPaidRequest(name, args);
449
+ if (built.error) return { ...base, status: "BAD_ARGUMENTS", reason: built.error };
450
+
451
+ let res;
452
+ try {
453
+ res = await fetch(built.url, { ...built.init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
454
+ } catch (e) {
455
+ return {
456
+ ...base,
457
+ status: "UNREACHABLE",
458
+ reason: e instanceof Error ? e.message : String(e),
459
+ note: "The route could not be fetched. Nothing was charged, and no result is invented.",
460
+ };
461
+ }
462
+
463
+ const text = await res.text();
464
+ let body = null;
465
+ try {
466
+ body = text ? JSON.parse(text) : null;
467
+ } catch {
468
+ body = { raw: text.slice(0, 2000) };
469
+ }
470
+
471
+ if (res.status === 402) {
472
+ return {
473
+ ...base,
474
+ status: "PAYMENT_REQUIRED",
475
+ http_status: 402,
476
+ payment_required: body,
477
+ payment_required_header: res.headers.get("payment-required"),
478
+ note:
479
+ "A challenge is an answer, not a failure. Pay from your own wallet against accepts[] and " +
480
+ "call again with x_payment. Nothing was charged by this call.",
481
+ };
482
+ }
483
+ if (res.status === 404) {
484
+ return { ...base, status: "NOT_DEPLOYED", http_status: 404, body, note: `${tool.csoai.route} is not on ${ORIGIN}.` };
485
+ }
486
+ if (res.ok) {
487
+ const settle = res.headers.get("x-payment-response");
488
+ return { ...base, status: "DELIVERED", http_status: res.status, body, ...(settle ? { x_payment_response: settle } : {}) };
489
+ }
490
+ return { ...base, status: String(res.status), http_status: res.status, body };
491
+ }
492
+
274
493
  const HANDLERS = {
275
494
  board_totals: boardTotals,
276
495
  get_axis: getAxis,
277
496
  verify_card: verifyCardTool,
278
497
  list_cards: listCards,
498
+ get_root: getRoot,
499
+ get_card: getCard,
500
+ verify_inclusion: verifyInclusion,
279
501
  };
280
502
 
281
503
  /* ----------------------------------------------------------------- transport */
@@ -311,6 +533,20 @@ function summaryLine(name, payload) {
311
533
  const b = payload.card_store_count_endpoint?.count ?? "?";
312
534
  return `index declares ${a} card rows; the store's count endpoint reports ${b}. Two labelled numbers, not reconciled here.`;
313
535
  }
536
+ case "get_root":
537
+ return `${payload.state ?? "?"} — public-root merkle ${(String(payload.merkle_root || "")).slice(0, 16) || "none"}. Not GSPC.`;
538
+ case "get_card":
539
+ return `${payload.state ?? "?"} — card-v0 leaf ${String(payload.sha256 || "").slice(0, 16) || "?"}.`;
540
+ case "verify_inclusion":
541
+ return `${payload.state ?? "?"} — inclusion against live merkle.`;
542
+ case "commission_card":
543
+ case "art50_marking_evidence":
544
+ case "rwa_evidence":
545
+ case "witness_hash":
546
+ case "receipts_batch":
547
+ return `${payload.status ?? "?"} — ${payload.route ?? name}${
548
+ payload.status === "PAYMENT_REQUIRED" ? "; nothing charged" : ""
549
+ }${payload.reason ? " — " + payload.reason : ""}. ${PAID_DOCTRINE}.`;
314
550
  default:
315
551
  return name;
316
552
  }
@@ -334,7 +570,7 @@ async function handle(msg) {
334
570
 
335
571
  if (method === "tools/call") {
336
572
  const name = params?.name;
337
- const fn = HANDLERS[name];
573
+ const fn = PAID_BY_NAME.has(name) ? (a) => callPaidTool(name, a) : HANDLERS[name];
338
574
  if (!fn) return replyError(id, -32602, `unknown tool: ${name}`);
339
575
  try {
340
576
  const payload = await fn(params?.arguments ?? {});
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "csoai-gspc-mcp",
3
- "version": "0.1.0",
4
- "description": "Stdio MCP server for the live GSPC board and the signed measurement cards at councilof.ai. Four honest tools: board_totals, get_axis, verify_card (pinned-key, three-state), list_cards. We measure, never certify; unmeasured is first-class; verdicts are VALID / INVALID / UNCHECKABLE, never two-state. Zero dependencies.",
3
+ "version": "0.2.0",
4
+ "description": "Stdio MCP server for the live GSPC board, signed measurement cards, and public-root merkle at councilof.ai. Twelve tools: seven free readers (board_totals, get_axis, verify_card with a pinned key and a three-state verdict, list_cards, get_root, get_card, verify_inclusion) and five x402-metered ones (commission_card, art50_marking_evidence, rwa_evidence, witness_hash, receipts_batch) that return the route's own 402 challenge until you pay. We measure, never certify; unmeasured is first-class; verdicts are VALID / INVALID / UNCHECKABLE, never two-state. A 404 leaf is INVALID. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "csoai-gspc-mcp": "index.mjs"
8
8
  },
9
9
  "main": "index.mjs",
10
10
  "files": [
11
- "index.mjs",
11
+ "README.md",
12
12
  "gspc-tools.json",
13
- "verify-card.mjs",
14
- "README.md"
13
+ "index.mjs",
14
+ "paid-tools.json",
15
+ "verify-card.mjs"
15
16
  ],
16
17
  "scripts": {
17
18
  "prepack": "node pack.mjs",
18
- "smoke": "node smoke.mjs"
19
+ "smoke": "node smoke.mjs",
20
+ "test:wired": "node wired-tools.test.mjs"
19
21
  },
20
22
  "keywords": [
21
23
  "mcp",
@@ -25,7 +27,16 @@
25
27
  "measurement",
26
28
  "ed25519",
27
29
  "provenance",
28
- "ai-governance"
30
+ "ai-governance",
31
+ "AI governance",
32
+ "model safety",
33
+ "EU AI Act",
34
+ "agent verification",
35
+ "model-safety",
36
+ "eu-ai-act",
37
+ "agent-verification",
38
+ "x402",
39
+ "attestation"
29
40
  ],
30
41
  "license": "Apache-2.0",
31
42
  "homepage": "https://councilof.ai",
@@ -0,0 +1,84 @@
1
+ {
2
+ "schema": "csoai.gspc-mcp-paid-tools/1",
3
+ "note": "PAID tools on the MCP doors. Each wraps one x402-metered Pages route. Unpaid, the tool returns the route's 402 challenge as structuredContent (accepts[], PAYMENT-REQUIRED) so an MCP client pays from its own wallet and calls again with x_payment. Payment travels as the x_payment ARGUMENT, not as a transport header, so the stdio server (mcp/gspc-server, npm csoai-gspc-mcp >= 0.2.0) carries these too: it forwards the argument verbatim as the X-PAYMENT header on one request to councilof.ai and never inspects, signs or invents a receipt. Before 0.2.0 the stdio server carried only the seven free tools; that was a packaging choice, never a limit of the transport. Every deliverable is measurement, not certification; no tool here carries or awards a trust label of any kind, and the catalogue is free. Amounts appear only inside a 402 challenge, never here.",
4
+ "doctrine": "measurement, not certification",
5
+ "tools": [
6
+ {
7
+ "name": "commission_card",
8
+ "description": "PAID (x402). Commission one signed card-v0 receipt (surface ras.commission) for a named subject on the frozen bank via https://councilof.ai/api/request-attestation. Re-serves every signed measurement card already on file for the subject; a payment never mints a MEASURED cell (fresh_run stays UNMEASURED until a published run exists). Without x_payment the tool returns the 402 challenge (accepts[] with asset, amount, payTo) plus a free preview of the cards already on file. Measurement, not certification — never a rank, a grade or a certificate. Verification stays free.",
9
+ "inputSchema": {
10
+ "type": "object",
11
+ "properties": {
12
+ "subject": { "type": "string", "description": "Subject to commission: a model id, instrument id or card sha (1-120 chars of [A-Za-z0-9._:/@+-])." },
13
+ "axis": { "type": "string", "description": "Optional axis slug; omit for the subject-level commission." },
14
+ "x_payment": { "type": "string", "description": "The X-PAYMENT header value (base64 x402 payload) signed by your wallet against accepts[] from the previous 402. Omit to receive the challenge." }
15
+ },
16
+ "required": ["subject"],
17
+ "additionalProperties": false
18
+ },
19
+ "csoai": { "paid": true, "rail": "x402", "route": "/api/request-attestation", "sku": "request_attestation", "free_preview": "the 402 body (csoai.preview)", "deliverable": "one card-v0 leaf, surface ras.commission, Ed25519 when the Pages key is present" }
20
+ },
21
+ {
22
+ "name": "art50_marking_evidence",
23
+ "description": "PAID (x402 or CSOAI LTD invoice). Article 50 marking-evidence pack via https://councilof.ai/api/art50/marking-evidence: is a machine-readable mark DETECTABLE in these bytes right now (C2PA manifest store, assertion hashes, hard binding, claim signature; IPTC digitalSourceType), beside the verbatim Art 50(2) excerpt hash and the Art 99(4) ceiling. Watermarks are UNCHECKABLE where no public detector exists and the pack says so. Point-in-time detection — never a conformity opinion, never a compliance word of any kind. preview=true is free and returns the same measurement unsigned. Without x_payment the tool returns the 402 challenge. If the route is not deployed on this origin the tool says NOT_DEPLOYED rather than inventing a result.",
24
+ "inputSchema": {
25
+ "type": "object",
26
+ "properties": {
27
+ "url": { "type": "string", "description": "Public http(s) URL of the generative output to measure (≤20 MiB)." },
28
+ "bytes_b64": { "type": "string", "description": "Alternatively the output bytes, base64 (≤20 MiB decoded)." },
29
+ "manifest_b64": { "type": "string", "description": "Alternatively a detached C2PA manifest store, base64 (manifest-only mode)." },
30
+ "preview": { "type": "boolean", "description": "true = free unsigned measurement (no card sha, no signature)." },
31
+ "x_payment": { "type": "string", "description": "The X-PAYMENT header value signed against accepts[] from the previous 402. Omit to receive the challenge." }
32
+ },
33
+ "additionalProperties": false
34
+ },
35
+ "csoai": { "paid": true, "rail": "x402-or-invoice", "route": "/api/art50/marking-evidence", "sku": "art50_marking_evidence", "free_preview": "preview=true", "deliverable": "one card-v0 leaf, surface art50.marking-evidence, kind csoai.art50.marking-evidence/0.1", "deployed_by": "live on councilof.ai (verified 2026-09-03)" }
36
+ },
37
+ {
38
+ "name": "rwa_evidence",
39
+ "description": "PAID (x402). Per-request signed evidence card of ONE XRPL issued asset's deterministic on-ledger state via https://councilof.ai/api/rwa/evidence: AccountRoot lsf* flags, Domain, the two-way xrp-ledger.toml check (PASS / FAIL / UNCHECKABLE — unreachable is never FAIL), gateway_balances obligation, holders as the free reader has them, every raw fetch sha256'd. preview=true is free (unsigned state). Historical state at fetched_at — not a rating, not a guarantee, not a conformity mark; /api/xrpl and /root.json stay free. Without x_payment the tool returns the 402 challenge. If the route is not deployed on this origin the tool says NOT_DEPLOYED.",
40
+ "inputSchema": {
41
+ "type": "object",
42
+ "properties": {
43
+ "asset": { "type": "string", "description": "Symbol (as listed by https://councilof.ai/api/xrpl) or issuer r-address." },
44
+ "preview": { "type": "boolean", "description": "true = free unsigned state." },
45
+ "x_payment": { "type": "string", "description": "The X-PAYMENT header value signed against accepts[] from the previous 402. Omit to receive the challenge." }
46
+ },
47
+ "required": ["asset"],
48
+ "additionalProperties": false
49
+ },
50
+ "csoai": { "paid": true, "rail": "x402", "route": "/api/rwa/evidence", "sku": "rwa_evidence", "free_preview": "preview=true", "deliverable": "one card-v0 leaf, surface public.notice, kind csoai.eater.xrpl-issuer/0.1", "deployed_by": "live on councilof.ai (verified 2026-09-03)" }
51
+ },
52
+ {
53
+ "name": "witness_hash",
54
+ "description": "PAID (x402 or CSOAI LTD invoice). Witness a SHA-256 digest — 'attest what you're shown' — via https://councilof.ai/api/witness: one public.notice leaf (csoai.witness.hash/0.1) in the next hourly signed public root, an RFC-3161 timestamp over the digest, and the root's Rekor + OpenTimestamps anchors. Hash-only: name a digest, or a public URL fetched once (robots honoured, never past a login, paywall or bot check → UNCHECKABLE, nothing charged). Attests existence of the digest at the root's as_of — nothing about content, legality or provenance; a self-signed card carries no legal presumption. Measurement, not certification. Status is free at /api/witness/status. Without x_payment the tool returns the 402 challenge. If the route is not deployed on this origin the tool says NOT_DEPLOYED.",
55
+ "inputSchema": {
56
+ "type": "object",
57
+ "properties": {
58
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "The digest to witness (64 lowercase hex). Or pass url." },
59
+ "url": { "type": "string", "description": "Optional public https URL, fetched once with our UA, hashed, never stored." },
60
+ "label": { "type": "string", "description": "Optional label (≤120 chars, no verdict words); appears on the leaf." },
61
+ "x_payment": { "type": "string", "description": "The X-PAYMENT header value signed against accepts[] from the previous 402. Omit to receive the challenge." }
62
+ },
63
+ "additionalProperties": false
64
+ },
65
+ "csoai": { "paid": true, "rail": "x402-or-invoice", "route": "/api/witness", "sku": "witness_hash", "free_status": "/api/witness/status?sha256=", "deliverable": "one public.notice leaf in the next hourly root + RFC-3161 reply + Rekor/OTS anchors", "deployed_by": "live on councilof.ai (verified 2026-09-03)" }
66
+ },
67
+ {
68
+ "name": "receipts_batch",
69
+ "description": "PAID (x402 or CSOAI LTD invoice). A historical batch of the estate's measurement receipts via https://councilof.ai/api/receipts/batch: every signed card-v0 leaf whose as_of falls in [from,to] (≤200), each with its Merkle inclusion path and the public root(s) that carried it, plus the root index for the window and one signed manifest card citing the batch sha256. preview=true is free and returns count, span, root count and the sha256 of the exact bytes the paid call returns. Recent leaves are free at /root.json, /cards/ and /api/proof?sha= — the batch sells assembly across history, never a conclusion. No settlement-receipt stream exists (/api/receipts/latest is UNPUBLISHED) and this tool never claims one. Without x_payment the tool returns the 402 challenge.",
70
+ "inputSchema": {
71
+ "type": "object",
72
+ "properties": {
73
+ "from": { "type": "string", "description": "Window start, ISO-8601. Required." },
74
+ "to": { "type": "string", "description": "Window end, ISO-8601. Defaults to now." },
75
+ "preview": { "type": "boolean", "description": "true = free: count, span, roots and batch sha256 without the leaves." },
76
+ "x_payment": { "type": "string", "description": "The X-PAYMENT header value signed against accepts[] from the previous 402. Omit to receive the challenge." }
77
+ },
78
+ "required": ["from"],
79
+ "additionalProperties": false
80
+ },
81
+ "csoai": { "paid": true, "rail": "x402-or-invoice", "route": "/api/receipts/batch", "sku": "receipts_batch", "free_preview": "preview=true", "deliverable": "csoai.receipts.batch/0.1 + one signed manifest card-v0 (surface receipts.batch)" }
82
+ }
83
+ ]
84
+ }
package/verify-card.mjs CHANGED
@@ -95,21 +95,17 @@ const unhex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)))
95
95
  * 2. ensure_ascii=False: non-ASCII stays literal (JS JSON.stringify does this), whereas
96
96
  * v1 escapes to \\uXXXX (CPython ensure_ascii=True).
97
97
  * 3. Same key-sort + compact separators + escape of quote/backslash/control chars.
98
- * Matches harness/arena/jcs.py (Python) and the conformance corpus in
99
- * harness/arena/jcs_conformance.py (18/18 ordinary cases agree cross-language).
98
+ * Matches harness/arena/jcs.py (Python ES6 NumberToString, not repr).
99
+ * Conformance: jcs_conformance.py including 1e-6 / 1e-7 / 1e21 / -0.
100
100
  */
101
101
  function canonicalJcs(value, key = null) {
102
102
  if (value === null) return "null";
103
103
  if (typeof value === "boolean") return value ? "true" : "false";
104
104
  if (typeof value === "number") {
105
105
  if (!Number.isFinite(value)) throw new Error(`non-finite number at ${key}`);
106
- // JCS/ECMAScript: integral floats in [10^-6, 10^21) emit as integers; -0 -> "0".
107
- if (Number.isInteger(value) && value !== 0) {
108
- const av = Math.abs(value);
109
- if (av >= 1e-6 && av < 1e21) return String(value);
110
- }
111
- if (value === 0) return "0"; // covers -0.0
112
- return String(value);
106
+ // RFC 8785: ES6 Number.prototype.toString. ToString(−0) is "0". Not JSON.stringify
107
+ // (that maps NaN/∞ to null — already rejected above). Not CPython json.dumps (0.0).
108
+ return Number.prototype.toString.call(value);
113
109
  }
114
110
  if (typeof value === "string") return jcsString(value);
115
111
  if (Array.isArray(value)) return "[" + value.map((v) => canonicalJcs(v, key)).join(",") + "]";