@ainra/mcp 0.4.1 → 0.5.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 +12 -3
- package/package.json +2 -2
- package/src/server.mjs +1 -1
- package/src/tools.mjs +131 -28
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->
|
|
2
2
|
# @ainra/mcp
|
|
3
3
|
|
|
4
|
-
[AINRA](https://ainra.
|
|
4
|
+
[AINRA](https://ainra.org/) as native agent tools. A zero-dependency MCP server that hands an assistant the
|
|
5
5
|
real verifier, the public record, and — only if you point it at a registrar you control — the ability to issue,
|
|
6
6
|
renew, and revoke.
|
|
7
7
|
|
|
@@ -33,8 +33,17 @@ There is no hosted default and no fallback. If the target isn't reachable the to
|
|
|
33
33
|
|
|
34
34
|
## Tools
|
|
35
35
|
|
|
36
|
-
**Read-only** — `ainra_verify`
|
|
37
|
-
|
|
36
|
+
**Read-only** — `ainra_verify` decides whether a presentation someone handed you verifies, and says why in plain
|
|
37
|
+
words. It takes the signed `directory` and the `roots` you trust (or fetches a URL target's own), checks the one
|
|
38
|
+
against the other, authenticates the status list against the registrar's status key, and uses **your** clock,
|
|
39
|
+
freshness class and audience. Nothing a presenter put in the bundle is believed. The result carries
|
|
40
|
+
`decision: "accept" | "refuse"` and the policy that decided.
|
|
41
|
+
|
|
42
|
+
> **In 0.4.1 this tool did not do that.** It replayed the bundle through the fixture-semantics verifier with the
|
|
43
|
+
> bundle's own clock, freshness class and status list, so a revoked or expired presenter could be reported `valid`
|
|
44
|
+
> (D-074). Upgrade. Replaying a conformance vector is still possible and is now a separate, labelled mode:
|
|
45
|
+
> `anchors` plus `fixture: true`, which returns `decision: null`.
|
|
46
|
+
|
|
38
47
|
`ainra_lookup` resolves a name to its public record; `ainra_status` reports the target's head — network, root label,
|
|
39
48
|
registrar count, freshness window.
|
|
40
49
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ainra/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"@ainra/sdk": "*"
|
|
24
24
|
},
|
|
25
|
-
"homepage": "https://ainra.
|
|
25
|
+
"homepage": "https://ainra.org/",
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
28
28
|
"url": "git+https://github.com/JacobJandon/ainra.git"
|
package/src/server.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, me
|
|
|
17
17
|
async function handle(msg) {
|
|
18
18
|
const { id, method, params } = msg;
|
|
19
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.` });
|
|
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/verify_link/lookup/status. Write (need confirm:true): ainra_issue/renew/revoke. Never targets a registrar you don't control.` });
|
|
21
21
|
if (method === "notifications/initialized" || method === "notifications/cancelled") return; // no response to notifications
|
|
22
22
|
if (method === "ping") return reply(id, {});
|
|
23
23
|
if (method === "tools/list")
|
package/src/tools.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { readFileSync, existsSync } from "node:fs";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
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
|
|
12
|
+
import { runVector, Verifier, verdictEvent, serializeVerdictEvent, linkFromIndex, jwkThumbprintEd25519, strictB64u } from "@ainra/sdk"; // file:../sdk-ts in a checkout; ^x.y.z when published
|
|
13
13
|
export { verdictEvent, serializeVerdictEvent }; // one event shape across CLI, middleware, MCP (docs/PRESENTATION.md)
|
|
14
14
|
|
|
15
15
|
const ROOT = fileURLToPath(new URL("../../../", import.meta.url));
|
|
@@ -100,50 +100,153 @@ export const TOOLS = [
|
|
|
100
100
|
{
|
|
101
101
|
name: "ainra_verify",
|
|
102
102
|
title: "Verify an AINRA passport",
|
|
103
|
-
description: "
|
|
103
|
+
description: "Decide whether a presentation someone handed you verifies. Pass the `presentation` with the signed `directory` and the two `roots` you trust (omit them and a URL target's own are fetched): the directory is checked against both roots, the status list against the registrar's status key, and the clock, the freshness class and the audience are YOURS — nothing a presenter put in the bundle is believed. Returns `decision` (accept or refuse), the verdict, and the named reason in plain words. Read-only. If the bundle carries an ADR-019 instance credential (a RUNNING COPY of an agent), pass your own `audience`; an empty audience refuses every instance credential. To replay a conformance VECTOR instead — which carries its own anchors, clock and status on purpose — pass `anchors` with `fixture: true`; that mode believes the bundle and decides nothing.",
|
|
104
104
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
105
105
|
inputSchema: {
|
|
106
106
|
type: "object",
|
|
107
107
|
properties: {
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
presentation: { type: "object", description: "The presentation bundle to verify, exactly as the presenter sent it." },
|
|
109
|
+
directory: { type: "object", description: "The dual-root-signed directory (directory.json) of the network you trust." },
|
|
110
|
+
roots: { type: "object", description: "The two ceremony root keys, { root_ed25519, root_slh } (roots.json). Obtain them out of band — never from the presenter." },
|
|
110
111
|
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." },
|
|
112
|
+
freshness: { type: "string", enum: ["F1", "F2", "F3"], description: "How old a status publication you accept: F1 30 s, F2 5 min (default), F3 24 h. Yours, never the bundle's." },
|
|
113
|
+
now: { type: "integer", description: "Your clock, unix seconds. Defaults to this server's clock. Never read from the bundle." },
|
|
114
|
+
anchors: { type: "object", description: "FIXTURE MODE ONLY: a conformance vector's own trust anchors (issuer_key + log_root_key per registrar). Requires `fixture: true`." },
|
|
115
|
+
fixture: { type: "boolean", description: "Replay a conformance vector with fixture semantics: the bundle's own clock, freshness class and status list are believed. Decides nothing about a real presenter." },
|
|
111
116
|
},
|
|
112
|
-
required: ["
|
|
117
|
+
required: ["presentation"],
|
|
113
118
|
},
|
|
114
|
-
handler(input) {
|
|
115
|
-
|
|
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);
|
|
119
|
+
async handler(input) {
|
|
120
|
+
const INSTANCE_REASONS = ["instance_expired", "instance_scope_exceeds", "instance_sig_invalid", "instance_pop_invalid"];
|
|
121
121
|
// Report the instance layer DISTINCTLY: an agent reading this must be able to tell "the running copy is not
|
|
122
122
|
// entitled" from "the lineage is not trusted", because the two have different remedies — renew the copy, or
|
|
123
123
|
// stop using the passport.
|
|
124
|
-
const
|
|
125
|
-
|
|
124
|
+
const instanceReport = (presentation, v, audience) => {
|
|
125
|
+
const inst = presentation.instance ?? null;
|
|
126
|
+
if (!inst) return { presented: false };
|
|
127
|
+
const atInstance = Boolean(v.reason && INSTANCE_REASONS.includes(v.reason));
|
|
128
|
+
return {
|
|
129
|
+
presented: true,
|
|
130
|
+
iid: inst.iid ?? null,
|
|
131
|
+
expires: inst.exp ?? null,
|
|
132
|
+
audience: audience || null,
|
|
133
|
+
layer: atInstance ? "instance" : v.verdict === "valid" ? "ok" : "passport",
|
|
134
|
+
note: atInstance
|
|
135
|
+
? "the RUNNING COPY is not entitled — the passport may be fine; mint a fresh instance credential"
|
|
136
|
+
: v.verdict === "valid" ? null
|
|
137
|
+
: "the failure is at the passport layer, not the running copy — the lineage itself did not verify",
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// ── fixture mode: a conformance vector, replayed. Explicit, labelled, and never a decision (D-074). ──────
|
|
142
|
+
if (input.anchors !== undefined && input.directory === undefined && input.roots === undefined) {
|
|
143
|
+
if (input.fixture !== true)
|
|
144
|
+
throw new Error(
|
|
145
|
+
"ainra_verify with `anchors` replays a conformance vector: it believes the bundle's own clock, freshness " +
|
|
146
|
+
"class and status list, so a revoked or expired presenter could make it say valid. To decide whether to " +
|
|
147
|
+
"trust a presentation, pass the signed `directory` and the `roots` instead. To replay a vector on " +
|
|
148
|
+
"purpose, add `fixture: true`. Nothing was verified.");
|
|
149
|
+
// The audience is the CALLER's even here; everything else is the vector's, which is what a vector is for.
|
|
150
|
+
const presentation = { ...input.presentation, audience: input.audience ?? "" };
|
|
151
|
+
const v = runVector({ name: "mcp", expect: {}, anchors: input.anchors, presentation });
|
|
152
|
+
return {
|
|
153
|
+
mode: "fixture",
|
|
154
|
+
decision: null,
|
|
155
|
+
warning: "FIXTURE SEMANTICS: the bundle's own clock, freshness class and status list were believed. This replays a conformance vector; it does not decide whether to trust a presenter.",
|
|
156
|
+
verdict: v.verdict,
|
|
157
|
+
reason: v.reason ?? null,
|
|
158
|
+
explanation: gloss(v),
|
|
159
|
+
event: verdictEvent(presentation, v, presentation?.now ?? 0),
|
|
160
|
+
instance: instanceReport(presentation, v, presentation.audience),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── gate mode: what a verifier decides. The directory against both roots; the status against the registrar's
|
|
165
|
+
// status key; the clock, the class and the audience from the CALLER. ─────────────────────────────────────────
|
|
166
|
+
let { directory, roots } = input;
|
|
167
|
+
let trust = "the directory and roots you passed";
|
|
168
|
+
if (directory === undefined && roots === undefined) {
|
|
169
|
+
if (!isUrl(TARGET))
|
|
170
|
+
throw new Error("ainra_verify needs the trust to verify against: pass the signed `directory` and the `roots`, or set AINRA_TARGET to the URL of a network that publishes /directory.json and /roots.json. Nothing was verified.");
|
|
171
|
+
[directory, roots] = [await getJson("/directory.json"), await getJson("/roots.json")];
|
|
172
|
+
trust = `directory.json and roots.json fetched from AINRA_TARGET (${TARGET})`;
|
|
173
|
+
}
|
|
174
|
+
if (!directory || !roots || typeof roots.root_ed25519 !== "string" || typeof roots.root_slh !== "string")
|
|
175
|
+
throw new Error("ainra_verify needs BOTH `directory` and `roots` ({ root_ed25519, root_slh }). Nothing was verified.");
|
|
176
|
+
const freshness = input.freshness ?? "F2";
|
|
177
|
+
if (!["F1", "F2", "F3"].includes(freshness)) throw new Error("freshness must be F1, F2 or F3. Nothing was verified.");
|
|
178
|
+
const audience = input.audience ?? "";
|
|
179
|
+
const verifier = Verifier.fromDirectoryB64(directory, roots.root_ed25519, roots.root_slh, freshness, false, audience);
|
|
180
|
+
if (!verifier)
|
|
181
|
+
throw new Error("the directory does not verify against the roots — nothing in it can be trusted, so nothing was verified.");
|
|
182
|
+
const now = Number.isInteger(input.now) ? input.now : Math.floor(Date.now() / 1000);
|
|
183
|
+
const v = verifier.verify(input.presentation, now);
|
|
126
184
|
return {
|
|
185
|
+
mode: "gate",
|
|
186
|
+
decision: v.verdict === "valid" ? "accept" : "refuse",
|
|
127
187
|
verdict: v.verdict,
|
|
128
188
|
reason: v.reason ?? null,
|
|
129
189
|
explanation: gloss(v),
|
|
130
|
-
event,
|
|
131
|
-
|
|
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 },
|
|
190
|
+
event: verdictEvent(input.presentation, v, now),
|
|
191
|
+
policy: { now, freshness, audience: audience || null, trust },
|
|
192
|
+
instance: instanceReport(input.presentation, v, audience),
|
|
144
193
|
};
|
|
145
194
|
},
|
|
146
195
|
},
|
|
196
|
+
{
|
|
197
|
+
name: "ainra_verify_link",
|
|
198
|
+
title: "Who stands behind this agent key?",
|
|
199
|
+
description: "AINRA Link (D-078): after you have verified a request's signature with an agent's Ed25519 key, ask which accountable operator stands behind that key and whether the link is still good. Pass the key (`key`, base64url), and either the `link` as a gate receives it, or the registrar's link `index` (GET /links) and its signed `status` publication (GET /status-list), from which the link for your key is found. Trust is YOURS: the signed `directory` and the two `roots` (omit both to use AINRA_TARGET's). The status list is authenticated before it is read; the clock, freshness class and capability are yours. Returns `decision` (accept or refuse), the operator and passport on accept, or the named reason. Read-only. A key with no link at all is refused as not_linked.",
|
|
200
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: "object",
|
|
203
|
+
properties: {
|
|
204
|
+
key: { type: "string", description: "The Ed25519 public key (base64url, 32 bytes) you ALREADY verified the request's signature with." },
|
|
205
|
+
link: { type: "object", description: "The link as a gate receives it (an index entry with the checkpoint and the signed status publication)." },
|
|
206
|
+
index: { type: "object", description: "Instead of `link`: the registrar's whole link index (GET /links)." },
|
|
207
|
+
status: { type: "object", description: "With `index`: the registrar's signed status publication (GET /status-list)." },
|
|
208
|
+
directory: { type: "object", description: "The dual-root-signed directory of the network you trust." },
|
|
209
|
+
roots: { type: "object", description: "The two ceremony root keys, { root_ed25519, root_slh }, obtained out of band." },
|
|
210
|
+
capability: { type: "string", description: "The capability the request exercises, if the link may be narrowed (e.g. read:invoices)." },
|
|
211
|
+
freshness: { type: "string", enum: ["F1", "F2", "F3"], description: "How old a status publication you accept: F1 30 s, F2 5 min (default), F3 24 h." },
|
|
212
|
+
now: { type: "integer", description: "Your clock, unix seconds. Defaults to this server's clock." },
|
|
213
|
+
},
|
|
214
|
+
required: ["key"],
|
|
215
|
+
},
|
|
216
|
+
async handler(input) {
|
|
217
|
+
const keyBytes = typeof input.key === "string" ? strictB64u(input.key) : null;
|
|
218
|
+
if (!keyBytes || keyBytes.length !== 32) throw new Error("`key` must be a base64url Ed25519 public key (32 bytes). Nothing was verified.");
|
|
219
|
+
let link = input.link;
|
|
220
|
+
if (link === undefined) {
|
|
221
|
+
if (!input.index || !input.status) throw new Error("pass either `link`, or both `index` (GET /links) and `status` (GET /status-list). Nothing was verified.");
|
|
222
|
+
link = linkFromIndex(input.index, input.status, jwkThumbprintEd25519(keyBytes));
|
|
223
|
+
if (!link)
|
|
224
|
+
return { decision: "refuse", verdict: "invalid", reason: "not_linked",
|
|
225
|
+
explanation: "This key has no link at this registrar: a valid signature from it is not, by itself, an accountable operator." };
|
|
226
|
+
}
|
|
227
|
+
let { directory, roots } = input;
|
|
228
|
+
let trust = "the directory and roots you passed";
|
|
229
|
+
if (directory === undefined && roots === undefined) {
|
|
230
|
+
if (!isUrl(TARGET))
|
|
231
|
+
throw new Error("ainra_verify_link needs the trust to verify against: pass the signed `directory` and the `roots`, or set AINRA_TARGET to the URL of a network that publishes /directory.json and /roots.json. Nothing was verified.");
|
|
232
|
+
[directory, roots] = [await getJson("/directory.json"), await getJson("/roots.json")];
|
|
233
|
+
trust = `directory.json and roots.json fetched from AINRA_TARGET (${TARGET})`;
|
|
234
|
+
}
|
|
235
|
+
if (!directory || !roots || typeof roots.root_ed25519 !== "string" || typeof roots.root_slh !== "string")
|
|
236
|
+
throw new Error("ainra_verify_link needs BOTH `directory` and `roots` ({ root_ed25519, root_slh }). Nothing was verified.");
|
|
237
|
+
const freshness = input.freshness ?? "F2";
|
|
238
|
+
if (!["F1", "F2", "F3"].includes(freshness)) throw new Error("freshness must be F1, F2 or F3. Nothing was verified.");
|
|
239
|
+
const verifier = Verifier.fromDirectoryB64(directory, roots.root_ed25519, roots.root_slh, freshness);
|
|
240
|
+
if (!verifier) throw new Error("the directory does not verify against the roots — nothing in it can be trusted, so nothing was verified.");
|
|
241
|
+
const now = Number.isInteger(input.now) ? input.now : Math.floor(Date.now() / 1000);
|
|
242
|
+
const v = verifier.verifyLink(link, keyBytes, now, input.capability);
|
|
243
|
+
return v.verdict === "valid"
|
|
244
|
+
? { decision: "accept", verdict: "valid", operator: v.operator, passport: v.passport, registrar: v.registrar, scope: v.scope,
|
|
245
|
+
explanation: `This key acts for operator "${v.operator}" under ${v.passport}, and the link has not been revoked.`,
|
|
246
|
+
policy: { now, freshness, capability: input.capability ?? null, trust } }
|
|
247
|
+
: { decision: "refuse", verdict: "invalid", reason: v.reason, explanation: gloss(v), policy: { now, freshness, capability: input.capability ?? null, trust } };
|
|
248
|
+
},
|
|
249
|
+
},
|
|
147
250
|
{
|
|
148
251
|
name: "ainra_lookup",
|
|
149
252
|
title: "Look up a public record",
|