@ainra/mcp 0.4.1
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 +66 -0
- package/package.json +31 -0
- package/src/reasons.json +24 -0
- package/src/server.mjs +63 -0
- package/src/tools.mjs +254 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->
|
|
2
|
+
# @ainra/mcp
|
|
3
|
+
|
|
4
|
+
[AINRA](https://ainra.vercel.app/) as native agent tools. A zero-dependency MCP server that hands an assistant the
|
|
5
|
+
real verifier, the public record, and — only if you point it at a registrar you control — the ability to issue,
|
|
6
|
+
renew, and revoke.
|
|
7
|
+
|
|
8
|
+
> **Not published to a registry.** This package is run from the repository. See `packages/mcp/` in
|
|
9
|
+
> [github.com/JacobJandon/ainra](https://github.com/JacobJandon/ainra).
|
|
10
|
+
|
|
11
|
+
## Configure
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"ainra": {
|
|
17
|
+
"command": "node",
|
|
18
|
+
"args": ["packages/mcp/src/server.mjs"],
|
|
19
|
+
"env": { "AINRA_TARGET": "genesis-out" }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`AINRA_TARGET` selects one thing and one thing only:
|
|
26
|
+
|
|
27
|
+
| Value | Means |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `genesis-out` (default) | a **local** registrar directory — what `make genesis-local` or `ainra init` produced — driven through the CLI |
|
|
30
|
+
| `http://127.0.0.1:8091` | a running AINRA network you control (`make stage-up`) |
|
|
31
|
+
|
|
32
|
+
There is no hosted default and no fallback. If the target isn't reachable the tools say so and stop.
|
|
33
|
+
|
|
34
|
+
## Tools
|
|
35
|
+
|
|
36
|
+
**Read-only** — `ainra_verify` runs the real `@ainra/sdk` verifier over a bundle and returns the verdict plus the
|
|
37
|
+
named reason in plain words (the same code that agrees byte-for-byte in the four-way conformance differential);
|
|
38
|
+
`ainra_lookup` resolves a name to its public record; `ainra_status` reports the target's head — network, root label,
|
|
39
|
+
registrar count, freshness window.
|
|
40
|
+
|
|
41
|
+
**Write, and each requires an explicit `confirm: true`** — `ainra_issue`, `ainra_renew`, and `ainra_revoke`.
|
|
42
|
+
`ainra_revoke` is destructive: it fails a live credential closed everywhere inside the freshness window. An agent
|
|
43
|
+
cannot reach any of the three by accident, and none of them targets a registrar you do not hold the write token for.
|
|
44
|
+
|
|
45
|
+
## Zero telemetry
|
|
46
|
+
|
|
47
|
+
The only outbound calls this server makes are to the `AINRA_TARGET` you set. Nothing is reported anywhere, by
|
|
48
|
+
design and by inspection — it is a few hundred lines with no dependencies.
|
|
49
|
+
|
|
50
|
+
Licensed Apache-2.0 OR MIT.
|
|
51
|
+
|
|
52
|
+
### Verifying a running copy (ADR-019)
|
|
53
|
+
|
|
54
|
+
`ainra_verify` takes an optional **`audience`** — yours. It is never read from the bundle, and an empty audience
|
|
55
|
+
refuses every instance credential (fail-closed). The result gains an `instance` object that reports the layer
|
|
56
|
+
distinctly:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{ "verdict": "invalid", "reason": "instance_expired",
|
|
60
|
+
"instance": { "presented": true, "iid": "i-0794", "expires": 2600, "layer": "instance",
|
|
61
|
+
"note": "the RUNNING COPY is not entitled — the passport may be fine; mint a fresh instance credential" } }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`layer` is `"instance"`, `"passport"` or `"ok"`, because the remedies differ: renew the copy, or stop trusting the
|
|
65
|
+
lineage. Minting is **not** an MCP tool — it needs a passport's control key, and that key belongs outside any
|
|
66
|
+
agent-reachable surface.
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ainra/mcp",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "AINRA as native agent tools — a zero-dependency MCP server wrapping the published verifier, the public record, and the registrar you control.",
|
|
5
|
+
"license": "Apache-2.0 OR MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ainra-mcp": "src/server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/tools.mjs"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/*.test.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@ainra/sdk": "*"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@ainra/sdk": "*"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://ainra.vercel.app/",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/JacobJandon/ainra.git"
|
|
29
|
+
},
|
|
30
|
+
"mcpName": "io.github.JacobJandon/ainra"
|
|
31
|
+
}
|
package/src/reasons.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_note": "The 20 frozen INVALID reasons, in verify order, each in plain words. Single source for the CLI/SDK/MCP/console legends and docs. The strings themselves are frozen (they match crates/ainra-core/src/verify.rs and packages/sdk-ts Reason); only these glosses are editorial. tools/reasons-check.mjs fails the build if this list and ainra-core's Reason::ALL ever diverge.",
|
|
3
|
+
"valid": "All nine checks passed — genuine, in-window, in-scope, logged, and not revoked.",
|
|
4
|
+
"schema_violation": "Not a well-formed passport, or it carries a forbidden (identity/score/money) field.",
|
|
5
|
+
"name_malformed": "The subject or issuer name doesn't fit the AINRA name grammar.",
|
|
6
|
+
"unknown_registrar": "The issuing registrar isn't in the signed directory (not accredited).",
|
|
7
|
+
"not_yet_valid": "Presented before its validity window opens (now < nbf).",
|
|
8
|
+
"expired": "Its validity window has closed (now >= exp) — no grace, no skew.",
|
|
9
|
+
"sig_invalid": "An issuer signature failed to verify.",
|
|
10
|
+
"alg_downgrade": "Only one of the two required signatures (Ed25519 + ML-DSA-65) was present or valid.",
|
|
11
|
+
"ceiling_exceeded": "The capabilities claimed exceed the scope ceiling.",
|
|
12
|
+
"chain_widening": "A delegation hop granted more than it held — delegation may only narrow.",
|
|
13
|
+
"chain_expired": "A delegation hop is outside its own validity window.",
|
|
14
|
+
"stale_status": "The revocation status is too old for the required freshness class (fails closed).",
|
|
15
|
+
"revoked": "The lineage's revocation bit is set — it has been revoked.",
|
|
16
|
+
"mandate_revoked": "A mandate in the signed permission path has been revoked.",
|
|
17
|
+
"not_logged": "The credential can't be proven present in the transparency log (logged-before-valid failed).",
|
|
18
|
+
"registrar_distrusted": "The registrar was distrusted from this point in its log onward — everything it logged before the published cutoff still verifies (D-044).",
|
|
19
|
+
"checkpoint_invalid": "The log checkpoint isn't signed by a currently-valid delegate.",
|
|
20
|
+
"instance_expired": "The running copy's instance credential is outside its window, or its lifetime exceeds the one-hour ADR-019 ceiling. The passport is fine — the copy needs a fresh credential.",
|
|
21
|
+
"instance_scope_exceeds": "The instance credential asks for a capability its passport does not hold. A running copy may only narrow, never widen (ADR-019).",
|
|
22
|
+
"instance_sig_invalid": "The instance credential was not minted by this passport's control key, or is not bound to this passport (wrong subject or wrong logged leaf).",
|
|
23
|
+
"instance_pop_invalid": "The presenter did not prove it holds the instance key: wrong audience, a timestamp outside tolerance, or a bad proof-of-possession signature."
|
|
24
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
3
|
+
// @ainra/mcp — a zero-dependency MCP server (JSON-RPC 2.0 over stdio, newline-delimited) exposing the AINRA tools.
|
|
4
|
+
// Agents are the users now; this meets them natively. No new protocol semantics — every tool wraps an existing
|
|
5
|
+
// surface (see tools.mjs). Zero telemetry: the only outbound calls are to the AINRA_TARGET the operator set.
|
|
6
|
+
// Run: node packages/mcp/src/server.mjs (or the `ainra-mcp` bin)
|
|
7
|
+
import { TOOLS, TOOL_BY_NAME, TARGET_INFO } from "./tools.mjs";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const VERSION = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
12
|
+
const PROTOCOL = "2025-06-18"; // the MCP revision this server speaks
|
|
13
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
14
|
+
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
15
|
+
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
16
|
+
|
|
17
|
+
async function handle(msg) {
|
|
18
|
+
const { id, method, params } = msg;
|
|
19
|
+
if (method === "initialize")
|
|
20
|
+
return reply(id, { protocolVersion: PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { name: "ainra", version: VERSION }, instructions: `AINRA tools. Target: ${TARGET_INFO.target} (${TARGET_INFO.mode}). Read-only: ainra_verify/lookup/status. Write (need confirm:true): ainra_issue/renew/revoke. Never targets a registrar you don't control.` });
|
|
21
|
+
if (method === "notifications/initialized" || method === "notifications/cancelled") return; // no response to notifications
|
|
22
|
+
if (method === "ping") return reply(id, {});
|
|
23
|
+
if (method === "tools/list")
|
|
24
|
+
return reply(id, { tools: TOOLS.map((t) => ({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema, annotations: { title: t.title, ...t.annotations } })) });
|
|
25
|
+
if (method === "tools/call") {
|
|
26
|
+
const tool = TOOL_BY_NAME[params?.name];
|
|
27
|
+
if (!tool) return fail(id, -32602, `unknown tool: ${params?.name}`);
|
|
28
|
+
try {
|
|
29
|
+
const result = await tool.handler(params.arguments || {});
|
|
30
|
+
return reply(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: result, isError: false });
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// Tool errors are reported IN-BAND (isError) so the agent sees the message and the next step, not a transport crash.
|
|
33
|
+
return reply(id, { content: [{ type: "text", text: String(e.message || e) }], isError: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (id !== undefined) return fail(id, -32601, `method not found: ${method}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// newline-delimited JSON-RPC over stdin. Track in-flight async handlers so a closed stdin (one-shot pipes, some
|
|
40
|
+
// agent runners) drains pending tool calls instead of dropping their responses on the floor.
|
|
41
|
+
let buf = "";
|
|
42
|
+
let inflight = 0;
|
|
43
|
+
process.stdin.setEncoding("utf8");
|
|
44
|
+
process.stdin.on("data", (chunk) => {
|
|
45
|
+
buf += chunk;
|
|
46
|
+
let nl;
|
|
47
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
48
|
+
const line = buf.slice(0, nl).trim();
|
|
49
|
+
buf = buf.slice(nl + 1);
|
|
50
|
+
if (!line) continue;
|
|
51
|
+
let msg;
|
|
52
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
53
|
+
inflight++;
|
|
54
|
+
Promise.resolve(handle(msg))
|
|
55
|
+
.catch((e) => { if (msg?.id !== undefined) fail(msg.id, -32603, String(e.message || e)); })
|
|
56
|
+
.finally(() => { inflight--; });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
process.stdin.on("end", () => {
|
|
60
|
+
const deadline = Date.now() + 30000; // drain up to 30s of pending tool calls, then exit regardless
|
|
61
|
+
const t = setInterval(() => { if (inflight === 0 || Date.now() > deadline) { clearInterval(t); process.exit(0); } }, 15);
|
|
62
|
+
});
|
|
63
|
+
process.stderr.write(`[ainra-mcp] up — ${TOOLS.length} tools, target ${TARGET_INFO.target} (${TARGET_INFO.mode}), zero telemetry\n`);
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
2
|
+
// @ainra/mcp — the AINRA tools an agent calls natively. Each tool maps 1:1 onto an EXISTING surface (the published
|
|
3
|
+
// @ainra/sdk verifier, the registrar's public record, the ainra CLI / registrar write API) — NO new protocol
|
|
4
|
+
// semantics. Read-only tools are marked; write/destructive tools require an explicit `confirm: true`. Zero telemetry:
|
|
5
|
+
// the only network calls are to the AINRA_TARGET the operator configured. One config field selects the target:
|
|
6
|
+
// AINRA_TARGET=genesis-out → a LOCAL registrar dir (genesis-local / `ainra init` output) via the CLI
|
|
7
|
+
// AINRA_TARGET=http://127.0.0.1:8091 → a network the operator controls (lookup/status read-only; issue/renew/revoke
|
|
8
|
+
// need AINRA_STAGE_ISSUE_TOKEN — never touch a registrar you don't hold keys for)
|
|
9
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { runVector, verdictEvent, serializeVerdictEvent } from "@ainra/sdk"; // file:../sdk-ts in a checkout; ^x.y.z when published
|
|
13
|
+
export { verdictEvent, serializeVerdictEvent }; // one event shape across CLI, middleware, MCP (docs/PRESENTATION.md)
|
|
14
|
+
|
|
15
|
+
const ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
16
|
+
// Installed from a registry there is no monorepo around this file, so everything it needs at import time must
|
|
17
|
+
// live inside the package. `src/reasons.json` is a committed copy of `docs/reasons.json`, held byte-identical by
|
|
18
|
+
// `make reasons-check`; the canonical file still wins in a checkout so a working tree is never stale.
|
|
19
|
+
const REASONS = JSON.parse(
|
|
20
|
+
readFileSync(
|
|
21
|
+
(() => {
|
|
22
|
+
const canonical = ROOT + "docs/reasons.json";
|
|
23
|
+
return existsSync(canonical) ? canonical : fileURLToPath(new URL("./reasons.json", import.meta.url));
|
|
24
|
+
})(),
|
|
25
|
+
"utf8",
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
const TARGET = process.env.AINRA_TARGET || "genesis-out";
|
|
29
|
+
const isUrl = (t) => /^https?:\/\//.test(t);
|
|
30
|
+
// The CLI is a compiled binary and cannot ship inside an npm tarball, so it is LOCATED, not bundled, in the order
|
|
31
|
+
// an operator would expect: an explicit path, then whatever `ainra` is on PATH, then a monorepo build. Absent all
|
|
32
|
+
// three the URL target still serves every read-only tool — only the local-dir and write paths need a binary.
|
|
33
|
+
const CLI_SOURCES = [
|
|
34
|
+
["AINRA_CLI", process.env.AINRA_CLI || null],
|
|
35
|
+
["PATH", (() => {
|
|
36
|
+
// stdio: stderr ignored — `which` prints "no ainra in ..." on a miss, and a miss is an ordinary outcome here,
|
|
37
|
+
// not an error worth putting on an MCP server's stderr at startup.
|
|
38
|
+
try { return execFileSync(process.platform === "win32" ? "where" : "which", ["ainra"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n")[0].trim() || null; }
|
|
39
|
+
catch { return null; }
|
|
40
|
+
})()],
|
|
41
|
+
["checkout", ROOT + "target/release/ainra"],
|
|
42
|
+
];
|
|
43
|
+
const BIN = (CLI_SOURCES.find(([, p]) => p && existsSync(p)) || [null, null])[1];
|
|
44
|
+
const gloss = (v) => (v.verdict === "valid" ? REASONS.valid : REASONS[v.reason] || "");
|
|
45
|
+
|
|
46
|
+
const NO_CLI =
|
|
47
|
+
'no ainra CLI found. This tool drives a LOCAL registrar directory, which needs the binary. Provide it in any of ' +
|
|
48
|
+
'three ways: set AINRA_CLI=/path/to/ainra, put `ainra` on PATH, or build it in a checkout ' +
|
|
49
|
+
'(`cargo build --release -p ainra-cli-rs`). Reads against a URL target need no binary — set ' +
|
|
50
|
+
'AINRA_TARGET=https://<your-network> instead.';
|
|
51
|
+
|
|
52
|
+
function cli(args) {
|
|
53
|
+
if (!BIN) throw new Error(`${NO_CLI}\n(AINRA_TARGET is currently "${TARGET}")`);
|
|
54
|
+
try { return execFileSync(BIN, args, { encoding: "utf8", timeout: 15000 }); }
|
|
55
|
+
catch (e) {
|
|
56
|
+
const out = (e.stderr || e.stdout || e.message || "").toString().trim();
|
|
57
|
+
throw new Error(`${out}\n(ran the CLI at ${BIN}; AINRA_TARGET must be a registrar dir you control — currently "${TARGET}")`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function getJson(path) {
|
|
61
|
+
let r;
|
|
62
|
+
try {
|
|
63
|
+
r = await fetch(TARGET.replace(/\/$/, "") + path, { signal: AbortSignal.timeout(5000) });
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(`cannot reach the target at ${TARGET} — is it up? Start one with \`make stage-up\` (staging) or set AINRA_TARGET to a running network / local registrar dir.`);
|
|
66
|
+
}
|
|
67
|
+
if (!r.ok) throw new Error(`target ${TARGET} returned HTTP ${r.status} for ${path} — check AINRA_TARGET points at a live AINRA network.`);
|
|
68
|
+
return r.json();
|
|
69
|
+
}
|
|
70
|
+
// One AINRA_TARGET, either shape: an artifact server publishes /registry.json (many registrars, read-only); a
|
|
71
|
+
// registrar daemon serves /export (its own records) + the write API. Agents get the SAME read surface from both.
|
|
72
|
+
async function readRegistry() {
|
|
73
|
+
try { return await getJson("/registry.json"); } catch { /* fall through to the registrar-daemon shape */ }
|
|
74
|
+
const ex = await getJson("/export");
|
|
75
|
+
return {
|
|
76
|
+
generated_window: { verified_at: ex.verified_at },
|
|
77
|
+
registrars: [ex],
|
|
78
|
+
totals: { registrars: 1, issued: (ex.records || []).length, revoked: (ex.records || []).filter((e) => e.record.revoked).length },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function postJson(path, body, what) {
|
|
82
|
+
const token = process.env.AINRA_STAGE_ISSUE_TOKEN || "";
|
|
83
|
+
const r = await fetch(TARGET.replace(/\/$/, "") + path, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
signal: AbortSignal.timeout(10000),
|
|
88
|
+
});
|
|
89
|
+
const text = await r.text();
|
|
90
|
+
if (!r.ok) throw new Error(`registrar ${what} returned HTTP ${r.status}: ${text.slice(0, 160)} — a write to a network registrar needs AINRA_STAGE_ISSUE_TOKEN (the operator's bearer token).`);
|
|
91
|
+
try { return JSON.parse(text); } catch { return { raw: text }; }
|
|
92
|
+
}
|
|
93
|
+
function requireConfirm(input, tool) {
|
|
94
|
+
if (input.confirm !== true)
|
|
95
|
+
throw new Error(`${tool} is a WRITE operation against a registrar you control. Re-call with \`confirm: true\` to proceed. Nothing was changed.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── the tools (def + handler). handler(input) → a plain object; the server wraps it as MCP content. ──
|
|
99
|
+
export const TOOLS = [
|
|
100
|
+
{
|
|
101
|
+
name: "ainra_verify",
|
|
102
|
+
title: "Verify an AINRA passport",
|
|
103
|
+
description: "Run the real @ainra/sdk verifier over a presentation bundle (with its trust anchors) and return the verdict plus the named reason in plain words. Read-only, offline, deterministic — the same code that agrees byte-for-byte in the conformance differential. If the bundle carries an ADR-019 instance credential (a RUNNING COPY of an agent rather than the agent itself), pass your own `audience`: it is never taken from the bundle, and an empty audience refuses every instance credential.",
|
|
104
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
anchors: { type: "object", description: "Trust anchors keyed by registrar id (issuer_key + log_root_key), as published in a directory/export." },
|
|
109
|
+
presentation: { type: "object", description: "The presentation bundle to verify." },
|
|
110
|
+
audience: { type: "string", description: "YOUR audience (ADR-019), e.g. https://api.example. Required to accept an instance credential; a presenter can never supply it. Omit for plain passport bundles." },
|
|
111
|
+
},
|
|
112
|
+
required: ["anchors", "presentation"],
|
|
113
|
+
},
|
|
114
|
+
handler(input) {
|
|
115
|
+
// The audience is the CALLER's, exactly as `now` is. It is spliced over whatever the bundle claimed, which
|
|
116
|
+
// is the same override the TS Verifier and the Python Verifier apply — a presenter naming its own audience
|
|
117
|
+
// would defeat audience binding entirely.
|
|
118
|
+
const presentation = { ...input.presentation, audience: input.audience ?? "" };
|
|
119
|
+
const v = runVector({ name: "mcp", expect: {}, anchors: input.anchors, presentation });
|
|
120
|
+
const event = verdictEvent(presentation, v, presentation?.now ?? 0);
|
|
121
|
+
// Report the instance layer DISTINCTLY: an agent reading this must be able to tell "the running copy is not
|
|
122
|
+
// entitled" from "the lineage is not trusted", because the two have different remedies — renew the copy, or
|
|
123
|
+
// stop using the passport.
|
|
124
|
+
const INSTANCE_REASONS = ["instance_expired", "instance_scope_exceeds", "instance_sig_invalid", "instance_pop_invalid"];
|
|
125
|
+
const inst = presentation.instance ?? null;
|
|
126
|
+
return {
|
|
127
|
+
verdict: v.verdict,
|
|
128
|
+
reason: v.reason ?? null,
|
|
129
|
+
explanation: gloss(v),
|
|
130
|
+
event,
|
|
131
|
+
instance: inst
|
|
132
|
+
? {
|
|
133
|
+
presented: true,
|
|
134
|
+
iid: inst.iid ?? null,
|
|
135
|
+
expires: inst.exp ?? null,
|
|
136
|
+
audience: presentation.audience || null,
|
|
137
|
+
layer: v.reason && INSTANCE_REASONS.includes(v.reason) ? "instance" : v.verdict === "valid" ? "ok" : "passport",
|
|
138
|
+
note: v.reason && INSTANCE_REASONS.includes(v.reason)
|
|
139
|
+
? "the RUNNING COPY is not entitled — the passport may be fine; mint a fresh instance credential"
|
|
140
|
+
: v.verdict === "valid" ? null
|
|
141
|
+
: "the failure is at the passport layer, not the running copy — the lineage itself did not verify",
|
|
142
|
+
}
|
|
143
|
+
: { presented: false },
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "ainra_lookup",
|
|
149
|
+
title: "Look up a public record",
|
|
150
|
+
description: "Resolve an AINRA name to its public record (tier, authority, standing, carried verdict) from the configured target's directory + log. Read-only.",
|
|
151
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
152
|
+
inputSchema: { type: "object", properties: { name: { type: "string", description: "The subject name, e.g. ainra:registrar-07:acme:invoicing@4.2.1" } }, required: ["name"] },
|
|
153
|
+
async handler(input) {
|
|
154
|
+
if (isUrl(TARGET)) {
|
|
155
|
+
const reg = await readRegistry();
|
|
156
|
+
for (const R of reg.registrars) for (const e of R.records)
|
|
157
|
+
if (e.record.sub === input.name)
|
|
158
|
+
return { name: e.record.sub, registrar: R.registrar, tier: e.record.tier, authority: e.record.auth_class, standing: e.record.revoked ? "revoked" : "active", verdict: e.verdict.verdict, reason: e.verdict.reason ?? null };
|
|
159
|
+
throw new Error(`no record for "${input.name}" in the target's registry — list what exists with ainra_status, or issue it with ainra_issue.`);
|
|
160
|
+
}
|
|
161
|
+
const out = cli(["present", TARGET, input.name]);
|
|
162
|
+
const rec = JSON.parse(out);
|
|
163
|
+
return { name: rec.sub, registrar: rec.registrar, tier: rec.tier, authority: rec.auth_class, standing: rec.revoked ? "revoked" : "active" };
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "ainra_status",
|
|
168
|
+
title: "Status / freshness of the target",
|
|
169
|
+
description: "Report the target's current head: network + root label, registrar count, and the verification window (freshness). Read-only.",
|
|
170
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
171
|
+
inputSchema: { type: "object", properties: {} },
|
|
172
|
+
async handler() {
|
|
173
|
+
if (isUrl(TARGET)) {
|
|
174
|
+
let health = null;
|
|
175
|
+
try { health = await getJson("/health"); } catch { /* artifact servers have no /health — registry totals still tell the story */ }
|
|
176
|
+
const reg = await readRegistry();
|
|
177
|
+
return {
|
|
178
|
+
target: TARGET,
|
|
179
|
+
network: health?.network ?? "staging", root: health?.root ?? "test-root",
|
|
180
|
+
registrar: health?.registrar, write_auth: health?.write_auth,
|
|
181
|
+
registrars: reg.totals.registrars, issued: reg.totals.issued, revoked: reg.totals.revoked,
|
|
182
|
+
lineages: reg.registrars.flatMap((R) => R.records.map((e) => e.record.sub)),
|
|
183
|
+
verified_at: reg.generated_window.verified_at, freshness: "F3", telemetry: "none",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { target: TARGET, mode: "local registrar dir", note: "use ainra_lookup <name> to read a record; ainra_status is fullest against a network target.", telemetry: "none" };
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "ainra_issue",
|
|
191
|
+
title: "Issue a passport (writes to a registrar you control)",
|
|
192
|
+
description: "Issue a new passport on the configured registrar (a LOCAL dir via the ainra CLI, or a network registrar you hold the write token for). WRITE operation: requires `confirm: true`.",
|
|
193
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
operator: { type: "string" }, lineage: { type: "string" }, version: { type: "string" },
|
|
198
|
+
tier: { type: "string", default: "L2" }, auth: { type: "string", default: "A2" },
|
|
199
|
+
capabilities: { type: "array", items: { type: "string" } },
|
|
200
|
+
confirm: { type: "boolean", description: "Must be true — this writes to your registrar." },
|
|
201
|
+
},
|
|
202
|
+
required: ["operator", "lineage", "version", "confirm"],
|
|
203
|
+
},
|
|
204
|
+
async handler(input) {
|
|
205
|
+
requireConfirm(input, "ainra_issue");
|
|
206
|
+
const caps = input.capabilities?.length ? input.capabilities : ["read:data"];
|
|
207
|
+
if (isUrl(TARGET)) {
|
|
208
|
+
const body = { operator: input.operator, lineage: input.lineage, version: input.version, tier: input.tier || "L2", auth_class: input.auth || "A2", principal_proof: "deadbeef" + input.lineage, capabilities: caps, scope_ceiling: caps, hops: [] };
|
|
209
|
+
const rec = await postJson("/issue", body, "issue");
|
|
210
|
+
return { issued: rec.sub ?? rec.record?.sub ?? `${input.operator}:${input.lineage}@${input.version}`, tier: rec.tier ?? body.tier, via: TARGET };
|
|
211
|
+
}
|
|
212
|
+
const args = ["issue", TARGET, "--operator", input.operator, "--lineage", input.lineage, "--version", input.version, "--tier", input.tier || "L2", "--auth", input.auth || "A2"];
|
|
213
|
+
for (const c of caps) args.push("--cap", c);
|
|
214
|
+
const out = cli(args).trim();
|
|
215
|
+
return { issued: out.split("\n")[0].replace(/^issued /, ""), detail: out };
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "ainra_renew",
|
|
220
|
+
title: "Renew a passport (writes to a registrar you control)",
|
|
221
|
+
description: "Reissue a lineage with a fresh validity window (ADR-017 renewal, prev_leaf continuity). WRITE operation: requires `confirm: true`.",
|
|
222
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
223
|
+
inputSchema: { type: "object", properties: { sub: { type: "string" }, version: { type: "string" }, confirm: { type: "boolean" } }, required: ["sub", "version", "confirm"] },
|
|
224
|
+
async handler(input) {
|
|
225
|
+
requireConfirm(input, "ainra_renew");
|
|
226
|
+
if (isUrl(TARGET)) {
|
|
227
|
+
// ADR-017 reissue over the registrar's public door — fresh window, prev_leaf continuity, new status bit.
|
|
228
|
+
const out = await postJson("/renew", { sub: input.sub, new_version: input.version, now: Math.floor(Date.now() / 1000) }, "renew");
|
|
229
|
+
return { renewed: input.sub, new_generation: out.sub ?? out.record?.sub ?? input.sub.replace(/@.*$/, "@" + input.version), via: TARGET };
|
|
230
|
+
}
|
|
231
|
+
const out = cli(["renew", TARGET, input.sub, "--version", input.version]).trim();
|
|
232
|
+
return { renewed: input.sub, detail: out };
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "ainra_revoke",
|
|
237
|
+
title: "Revoke a passport (DESTRUCTIVE — writes to a registrar you control)",
|
|
238
|
+
description: "Set a lineage's revocation bit; it fails closed everywhere within the freshness window. DESTRUCTIVE and requires `confirm: true`.",
|
|
239
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
240
|
+
inputSchema: { type: "object", properties: { sub: { type: "string" }, confirm: { type: "boolean", description: "Must be true — this revokes a live credential." } }, required: ["sub", "confirm"] },
|
|
241
|
+
async handler(input) {
|
|
242
|
+
requireConfirm(input, "ainra_revoke");
|
|
243
|
+
if (isUrl(TARGET)) {
|
|
244
|
+
await postJson("/revoke", { sub: input.sub, now: Math.floor(Date.now() / 1000) }, "revoke");
|
|
245
|
+
return { revoked: input.sub, via: TARGET, note: "fails closed everywhere within the freshness window" };
|
|
246
|
+
}
|
|
247
|
+
const out = cli(["revoke", TARGET, input.sub]).trim();
|
|
248
|
+
return { revoked: input.sub, detail: out.split("\n")[0] };
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
export const TOOL_BY_NAME = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
|
|
254
|
+
export const TARGET_INFO = { target: TARGET, mode: isUrl(TARGET) ? "network" : "local-dir" };
|