@crawlcheck/sdk 1.13.0 → 1.14.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 +13 -0
- package/dist/continuity.d.ts +7 -2
- package/dist/continuity.js +94 -8
- package/dist/index.d.ts +52 -5
- package/dist/index.js +26 -4
- package/dist/jcs.d.ts +37 -0
- package/dist/jcs.js +131 -0
- package/dist/mcp.js +39 -10
- package/examples/receipt-roundtrip.mjs +28 -0
- package/package.json +1 -1
- package/test/fixtures/continuity-gen.mjs +41 -2
- package/test/fixtures/continuity.json +19053 -0
- package/test/fixtures/jcs-v1.json +1603 -0
- package/test/jcs.test.js +54 -0
- package/test/mcp.test.js +13 -0
package/README.md
CHANGED
|
@@ -205,6 +205,19 @@ const gate = await openContinuityGate(signed, { mode: "enforce", operatorKeys: [
|
|
|
205
205
|
|
|
206
206
|
`verifyLockBaseline(lock, opts)` runs the same check on its own. The signature is Ed25519 over `"crawlcheck-lock-operator-v1\n" + sha256(canonical JSON of the lockfile without operator_signature)`; the Python package signs and verifies byte for byte the same.
|
|
207
207
|
|
|
208
|
+
### Compare on jcs-v1 (1.14.0)
|
|
209
|
+
|
|
210
|
+
Every lockfile from `cc.mcpLock(url)` / `GET /api/v1/mcp/lock` since 2026-11-07 carries `jcs_v1`: RFC 8785 digests of each tool's contract (`name`, `description`, `inputSchema`, `outputSchema`) and of everything else it carries (`title`, `annotations`, `icons`, `_meta`, vendor members). The SDK now computes the same digests offline, byte for byte:
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
import { jcsV1 } from "@crawlcheck/sdk";
|
|
214
|
+
const d = await jcsV1(tools); // { tools_sha256: "sha256-…", extras_sha256: "sha256-…", count, tools: [{ name, contract, extras }] }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
When the approved lockfile carries `jcs_v1`, `continuityCheck` and every gate compare on it, over every tool: a moved contract is named `description_changed` or `schema_changed` (from `parts`), a moved extras digest `annotations_changed`, and any other change (a new `title`, `outputSchema`, `_meta`) `tool_changed` with a `detail`. Records say `baseline.alg: "jcs-v1"`. Pass `digest: "lk-v1"` to compare on the v1 hashes instead. A missing `description` and `""` are the same contract under jcs-v1 (see the spec's notes on empty fields).
|
|
218
|
+
|
|
219
|
+
Nothing is cut silently. `withPreflight` follows `nextCursor` to the last page; a list whose pages could not all be read is `tool_list_truncated` (and a v1 lockfile refuses it). A v1 lockfile hashes at most `LOCK_V1_MAX_TOOLS` (300) tools: past that, `lockFromTools` returns `complete: false` with `offered`, `checkLock` answers `verdict: "incomplete"`, `decision: "block"`, and a v1 continuity check raises `tool_list_truncated`. jcs-v1 has no cap.
|
|
220
|
+
|
|
208
221
|
## Do not trust the API: check the evidence yourself
|
|
209
222
|
|
|
210
223
|
```js
|
package/dist/continuity.d.ts
CHANGED
|
@@ -36,8 +36,11 @@ export declare function lockToolParts(t: any): Promise<{
|
|
|
36
36
|
schema_sha256: string;
|
|
37
37
|
annotations_sha256: string;
|
|
38
38
|
}>;
|
|
39
|
-
/** parts for a lockfile: { [tool name]: lockToolParts(tool) }. Store it as lock.parts next to lock.tools. */
|
|
39
|
+
/** parts for a lockfile: { [tool name]: lockToolParts(tool) }, for every tool given. Store it as lock.parts next to lock.tools. */
|
|
40
40
|
export declare function lockParts(tools: any): Promise<{}>;
|
|
41
|
+
/** The most tools a v1 (lk) lockfile hashes; CrawlCheck's lockfile endpoint hashes the same. A longer list is compared on
|
|
42
|
+
* its first LOCK_V1_MAX_TOOLS tools and the read is marked incomplete (tool_list_truncated), never cut silently. */
|
|
43
|
+
export declare const LOCK_V1_MAX_TOOLS = 300;
|
|
41
44
|
/** The policy in force: the default with the operator's values over it, and its sha256 (what the record names). */
|
|
42
45
|
export declare function continuityPolicy(p: any): Promise<{
|
|
43
46
|
v: number;
|
|
@@ -74,7 +77,9 @@ export declare function continuityPolicy(p: any): Promise<{
|
|
|
74
77
|
* Compare the offered tools with the approved baseline (an mcp-lockfile, ideally with lock.parts) and apply the policy.
|
|
75
78
|
* opts: mode ("observe" | "review" | "enforce", default "enforce"), policy, exceptions [{tool, class, until, by, reason}],
|
|
76
79
|
* complete (false when tools/list returned a nextCursor you did not follow), server (the URL checked), serverIdentity
|
|
77
|
-
* (the URL the baseline was approved for; differs -> server_identity_mismatch), expectPolicySha256, now (ms)
|
|
80
|
+
* (the URL the baseline was approved for; differs -> server_identity_mismatch), expectPolicySha256, now (ms), digest
|
|
81
|
+
* ("lk-v1" to compare on the v1 hashes even when the lockfile carries jcs_v1; by default a lockfile with jcs_v1 is
|
|
82
|
+
* compared on jcs-v1, over every tool). A v1 comparison of more than LOCK_V1_MAX_TOOLS tools is marked incomplete.
|
|
78
83
|
*/
|
|
79
84
|
export declare function continuityCheck(lock: any, tools: any, opts?: {}): Promise<any>;
|
|
80
85
|
/** A record for a check that could not compare anything (the server, the evidence or the service failed). cls: one of CONTINUITY_FAILURE_CLASSES. */
|
package/dist/continuity.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Effects, weakest to strongest: proceed, proceed_recorded (an approved exception), refresh, pause_tool, pause, deny.
|
|
17
17
|
// A failed exact comparison is never overridden by an interpretive assessment: nothing here reads descriptions for intent.
|
|
18
18
|
import { canon, keyPolicyCheck } from "./verify.js";
|
|
19
|
+
import { jcsV1 } from "./jcs.js";
|
|
19
20
|
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
20
21
|
const enc = new TextEncoder();
|
|
21
22
|
const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -57,9 +58,17 @@ export async function lockToolParts(t) {
|
|
|
57
58
|
const schema = t?.inputSchema ?? t?.input_schema ?? null;
|
|
58
59
|
return { description_sha256: await sha256Hex(canon(t?.description ?? null)), schema_sha256: await sha256Hex(canon(schema)), annotations_sha256: await sha256Hex(canon(t?.annotations ?? null)) };
|
|
59
60
|
}
|
|
60
|
-
/** parts for a lockfile: { [tool name]: lockToolParts(tool) }. Store it as lock.parts next to lock.tools. */
|
|
61
|
-
export async function lockParts(tools) { const o = {}; for (const t of (tools ?? [])
|
|
61
|
+
/** parts for a lockfile: { [tool name]: lockToolParts(tool) }, for every tool given. Store it as lock.parts next to lock.tools. */
|
|
62
|
+
export async function lockParts(tools) { const o = {}; for (const t of (tools ?? []))
|
|
62
63
|
o[String(t?.name ?? "").slice(0, 120)] = await lockToolParts(t); return o; }
|
|
64
|
+
/** The most tools a v1 (lk) lockfile hashes; CrawlCheck's lockfile endpoint hashes the same. A longer list is compared on
|
|
65
|
+
* its first LOCK_V1_MAX_TOOLS tools and the read is marked incomplete (tool_list_truncated), never cut silently. */
|
|
66
|
+
export const LOCK_V1_MAX_TOOLS = 300;
|
|
67
|
+
/** The lockfile's jcs-v1 block when it can be compared on (a digest, and a per-tool row for every tool it counted). */
|
|
68
|
+
function jcsBaseline(lock) {
|
|
69
|
+
const J = lock && lock.jcs_v1;
|
|
70
|
+
return J && typeof J === "object" && J.alg === "jcs-v1" && typeof J.tools_sha256 === "string" && J.tools_sha256 && Array.isArray(J.tools) && J.tools.length === J.count ? J : null;
|
|
71
|
+
}
|
|
63
72
|
async function toolHash(t) { return sha256Hex(canon({ name: t?.name ?? null, description: t?.description ?? null, inputSchema: t?.inputSchema ?? t?.input_schema ?? null, annotations: t?.annotations ?? null })); }
|
|
64
73
|
/** The policy in force: the default with the operator's values over it, and its sha256 (what the record names). */
|
|
65
74
|
export async function continuityPolicy(p) {
|
|
@@ -123,7 +132,9 @@ function ev(rec, pol, mode, cls, tool, exceptions, now, detail) {
|
|
|
123
132
|
* Compare the offered tools with the approved baseline (an mcp-lockfile, ideally with lock.parts) and apply the policy.
|
|
124
133
|
* opts: mode ("observe" | "review" | "enforce", default "enforce"), policy, exceptions [{tool, class, until, by, reason}],
|
|
125
134
|
* complete (false when tools/list returned a nextCursor you did not follow), server (the URL checked), serverIdentity
|
|
126
|
-
* (the URL the baseline was approved for; differs -> server_identity_mismatch), expectPolicySha256, now (ms)
|
|
135
|
+
* (the URL the baseline was approved for; differs -> server_identity_mismatch), expectPolicySha256, now (ms), digest
|
|
136
|
+
* ("lk-v1" to compare on the v1 hashes even when the lockfile carries jcs_v1; by default a lockfile with jcs_v1 is
|
|
137
|
+
* compared on jcs-v1, over every tool). A v1 comparison of more than LOCK_V1_MAX_TOOLS tools is marked incomplete.
|
|
127
138
|
*/
|
|
128
139
|
export async function continuityCheck(lock, tools, opts = {}) {
|
|
129
140
|
const mode = opts.mode || "enforce";
|
|
@@ -135,8 +146,12 @@ export async function continuityCheck(lock, tools, opts = {}) {
|
|
|
135
146
|
ev(rec, pol, mode, "response_malformed", null, X, now);
|
|
136
147
|
return finish(rec, mode);
|
|
137
148
|
}
|
|
149
|
+
const J = opts.digest === "lk-v1" ? null : jcsBaseline(lock);
|
|
150
|
+
if (J)
|
|
151
|
+
return jcsCheck(rec, pol, mode, lock, J, tools, opts, X, now);
|
|
152
|
+
const capped = tools.length > LOCK_V1_MAX_TOOLS, complete = opts.complete !== false && !capped;
|
|
138
153
|
const rows = [];
|
|
139
|
-
for (const t of tools.slice(0,
|
|
154
|
+
for (const t of tools.slice(0, LOCK_V1_MAX_TOOLS))
|
|
140
155
|
rows.push({ name: String(t?.name ?? "").slice(0, 120), sha256: await toolHash(t), t });
|
|
141
156
|
const cur = {};
|
|
142
157
|
rows.forEach((r) => { cur[r.name] = r; });
|
|
@@ -145,7 +160,9 @@ export async function continuityCheck(lock, tools, opts = {}) {
|
|
|
145
160
|
(lock?.tools ?? []).forEach((r) => { was[r.name] = r.sha256; });
|
|
146
161
|
const approvedAt = Date.parse(lock?.approved_at ?? "");
|
|
147
162
|
rec.baseline = { tools_sha256: lock?.tools_sha256 ?? null, approved_at: lock?.approved_at ?? null, age_days: isFinite(approvedAt) ? Math.floor((now - approvedAt) / 864e5) : null, server: lock?.server?.url ?? null, per_part: !!(lock && lock.parts), trust: opts.baselineTrust ?? null };
|
|
148
|
-
rec.observed = { tools_sha256: listHash, count: rows.length, complete
|
|
163
|
+
rec.observed = { tools_sha256: listHash, count: rows.length, complete };
|
|
164
|
+
if (capped)
|
|
165
|
+
rec.observed.offered = tools.length;
|
|
149
166
|
if (opts.expectPolicySha256 && opts.expectPolicySha256 !== pol.sha256)
|
|
150
167
|
ev(rec, pol, mode, "policy_version_mismatch", null, X, now, "expected " + opts.expectPolicySha256 + ", in force " + pol.sha256);
|
|
151
168
|
const want = opts.serverIdentity ?? lock?.server?.url ?? null;
|
|
@@ -153,11 +170,11 @@ export async function continuityCheck(lock, tools, opts = {}) {
|
|
|
153
170
|
ev(rec, pol, mode, "server_identity_mismatch", null, X, now, "baseline approved for " + want + ", checked " + opts.server);
|
|
154
171
|
if (rec.baseline.age_days !== null && rec.baseline.age_days > pol.max_baseline_age_days)
|
|
155
172
|
ev(rec, pol, mode, "baseline_stale", null, X, now, rec.baseline.age_days + " days old; the policy allows " + pol.max_baseline_age_days);
|
|
156
|
-
if (
|
|
157
|
-
ev(rec, pol, mode, "tool_list_truncated", null, X, now);
|
|
173
|
+
if (!complete)
|
|
174
|
+
ev(rec, pol, mode, "tool_list_truncated", null, X, now, capped ? "the server offers " + tools.length + " tools; a v1 lockfile hashes the first " + LOCK_V1_MAX_TOOLS : undefined);
|
|
158
175
|
for (const n of Object.keys(cur).filter((n) => !(n in was)).sort())
|
|
159
176
|
ev(rec, pol, mode, "tool_added", n, X, now);
|
|
160
|
-
if (
|
|
177
|
+
if (complete)
|
|
161
178
|
for (const n of Object.keys(was).filter((n) => !(n in cur)).sort())
|
|
162
179
|
ev(rec, pol, mode, "tool_removed", n, X, now);
|
|
163
180
|
for (const n of Object.keys(cur).filter((n) => n in was && was[n] !== cur[n].sha256).sort()) {
|
|
@@ -187,6 +204,75 @@ export async function continuityCheck(lock, tools, opts = {}) {
|
|
|
187
204
|
rec.events.push({ class: "unchanged", tool: null, why: "the offered tools match the approved baseline", policy_effect: pol.effects.unchanged, effect: modeEffect(mode, "unchanged", pol.effects.unchanged) });
|
|
188
205
|
return finish(rec, mode);
|
|
189
206
|
}
|
|
207
|
+
// 1.14.0: the comparison on jcs-v1 (https://crawlcheck.io/spec/mcp-lockfile#jcs-v1), used when the lockfile carries a
|
|
208
|
+
// jcs_v1 block (every CrawlCheck lockfile since 2026-11-07). Every tool is digested (no cap). A moved contract digest is
|
|
209
|
+
// named description_changed / schema_changed from lock.parts when it has them; a moved extras digest is
|
|
210
|
+
// annotations_changed when the annotations moved, otherwise tool_changed (title, icons, _meta or another member).
|
|
211
|
+
async function jcsCheck(rec, pol, mode, lock, J, tools, opts, X, now) {
|
|
212
|
+
const complete = opts.complete !== false, cj = await jcsV1(tools, { partial: false });
|
|
213
|
+
const approvedAt = Date.parse(lock?.approved_at ?? "");
|
|
214
|
+
rec.baseline = { alg: "jcs-v1", tools_sha256: J.tools_sha256, extras_sha256: J.extras_sha256 ?? null, approved_at: lock?.approved_at ?? null, age_days: isFinite(approvedAt) ? Math.floor((now - approvedAt) / 864e5) : null, server: lock?.server?.url ?? null, per_part: !!(lock && lock.parts), trust: opts.baselineTrust ?? null };
|
|
215
|
+
rec.observed = { alg: "jcs-v1", tools_sha256: cj.tools_sha256, extras_sha256: cj.tools_sha256 === null ? null : cj.extras_sha256, count: Array.isArray(tools) ? tools.length : 0, complete };
|
|
216
|
+
if (opts.expectPolicySha256 && opts.expectPolicySha256 !== pol.sha256)
|
|
217
|
+
ev(rec, pol, mode, "policy_version_mismatch", null, X, now, "expected " + opts.expectPolicySha256 + ", in force " + pol.sha256);
|
|
218
|
+
const want = opts.serverIdentity ?? lock?.server?.url ?? null;
|
|
219
|
+
if (want && opts.server && String(want).replace(/\/+$/, "") !== String(opts.server).replace(/\/+$/, ""))
|
|
220
|
+
ev(rec, pol, mode, "server_identity_mismatch", null, X, now, "baseline approved for " + want + ", checked " + opts.server);
|
|
221
|
+
if (rec.baseline.age_days !== null && rec.baseline.age_days > pol.max_baseline_age_days)
|
|
222
|
+
ev(rec, pol, mode, "baseline_stale", null, X, now, rec.baseline.age_days + " days old; the policy allows " + pol.max_baseline_age_days);
|
|
223
|
+
if (cj.tools_sha256 === null) {
|
|
224
|
+
ev(rec, pol, mode, "response_malformed", null, X, now, "the tool list has no jcs-v1 digest: " + cj.reason);
|
|
225
|
+
return finish(rec, mode);
|
|
226
|
+
}
|
|
227
|
+
const names = cj.tools.map((r) => r.name), dups = names.filter((n, i) => i > 0 && names[i - 1] === n).filter((n, i, a) => a.indexOf(n) === i);
|
|
228
|
+
if (dups.length) {
|
|
229
|
+
ev(rec, pol, mode, "response_malformed", null, X, now, "the tool list names a tool more than once: " + dups.slice(0, 10).join(", "));
|
|
230
|
+
return finish(rec, mode);
|
|
231
|
+
}
|
|
232
|
+
if (!complete)
|
|
233
|
+
ev(rec, pol, mode, "tool_list_truncated", null, X, now);
|
|
234
|
+
const was = {}, cur = {}, raw = {};
|
|
235
|
+
J.tools.forEach((r) => { if (r && typeof r.name === "string")
|
|
236
|
+
was[r.name] = r; });
|
|
237
|
+
cj.tools.forEach((r) => { cur[r.name] = r; });
|
|
238
|
+
tools.forEach((t) => { raw[t.name] = t; });
|
|
239
|
+
const n0 = rec.events.length;
|
|
240
|
+
for (const n of Object.keys(cur).filter((n) => !(n in was)).sort())
|
|
241
|
+
ev(rec, pol, mode, "tool_added", n, X, now);
|
|
242
|
+
if (complete)
|
|
243
|
+
for (const n of Object.keys(was).filter((n) => !(n in cur)).sort())
|
|
244
|
+
ev(rec, pol, mode, "tool_removed", n, X, now);
|
|
245
|
+
for (const n of Object.keys(cur).filter((n) => n in was).sort()) {
|
|
246
|
+
const a = was[n], b = cur[n], dc = a.contract !== b.contract, dx = a.extras !== b.extras;
|
|
247
|
+
if (!dc && !dx)
|
|
248
|
+
continue;
|
|
249
|
+
const P = lock && lock.parts && lock.parts[n], q = P ? await lockToolParts(raw[n]) : null, moved = [];
|
|
250
|
+
if (dc && P && q.description_sha256 !== P.description_sha256) {
|
|
251
|
+
ev(rec, pol, mode, "description_changed", n, X, now);
|
|
252
|
+
moved.push("d");
|
|
253
|
+
}
|
|
254
|
+
if (dc && P && q.schema_sha256 !== P.schema_sha256) {
|
|
255
|
+
ev(rec, pol, mode, "schema_changed", n, X, now);
|
|
256
|
+
moved.push("s");
|
|
257
|
+
}
|
|
258
|
+
if (dx && P && q.annotations_sha256 !== P.annotations_sha256) {
|
|
259
|
+
ev(rec, pol, mode, "annotations_changed", n, X, now);
|
|
260
|
+
moved.push("a");
|
|
261
|
+
}
|
|
262
|
+
const rest = [];
|
|
263
|
+
if (dc && !moved.some((m) => m === "d" || m === "s"))
|
|
264
|
+
rest.push(P ? "the contract changed outside description and inputSchema (outputSchema)" : "the contract (description, inputSchema or outputSchema) changed");
|
|
265
|
+
if (dx && moved.indexOf("a") < 0)
|
|
266
|
+
rest.push(P ? "a member outside the contract changed (title, icons, _meta or another; not annotations)" : "a member outside the contract changed (title, annotations, icons, _meta or another)");
|
|
267
|
+
if (rest.length)
|
|
268
|
+
ev(rec, pol, mode, "tool_changed", n, X, now, rest.join("; "));
|
|
269
|
+
}
|
|
270
|
+
if (complete && rec.events.length === n0 && (cj.tools_sha256 !== J.tools_sha256 || (cj.extras_sha256 || "") !== (J.extras_sha256 || "")))
|
|
271
|
+
ev(rec, pol, mode, "response_malformed", null, X, now, "the tool set's jcs-v1 digest differs from the approved one, but no single tool's does");
|
|
272
|
+
if (!rec.events.length)
|
|
273
|
+
rec.events.push({ class: "unchanged", tool: null, why: "the offered tools match the approved baseline", policy_effect: pol.effects.unchanged, effect: modeEffect(mode, "unchanged", pol.effects.unchanged) });
|
|
274
|
+
return finish(rec, mode);
|
|
275
|
+
}
|
|
190
276
|
/** A record for a check that could not compare anything (the server, the evidence or the service failed). cls: one of CONTINUITY_FAILURE_CLASSES. */
|
|
191
277
|
export async function continuityFailure(cls, opts = {}) {
|
|
192
278
|
if (CONTINUITY_FAILURE_CLASSES.indexOf(cls) < 0)
|
package/dist/index.d.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface ContinuityRecord {
|
|
|
59
59
|
};
|
|
60
60
|
claims: string;
|
|
61
61
|
baseline: {
|
|
62
|
+
alg?: "jcs-v1";
|
|
63
|
+
extras_sha256?: string | null;
|
|
62
64
|
tools_sha256: string | null;
|
|
63
65
|
approved_at: string | null;
|
|
64
66
|
age_days: number | null;
|
|
@@ -67,9 +69,12 @@ export interface ContinuityRecord {
|
|
|
67
69
|
trust: BaselineTrust["trust"] | null;
|
|
68
70
|
} | null;
|
|
69
71
|
observed: {
|
|
70
|
-
|
|
72
|
+
alg?: "jcs-v1";
|
|
73
|
+
extras_sha256?: string | null;
|
|
74
|
+
tools_sha256: string | null;
|
|
71
75
|
count: number;
|
|
72
|
-
complete: boolean;
|
|
76
|
+
complete: boolean; /** 1.14.0: the number of tools offered, when more than a v1 lockfile hashes (the read is then incomplete). */
|
|
77
|
+
offered?: number;
|
|
73
78
|
} | null;
|
|
74
79
|
events: ContinuityEvent[];
|
|
75
80
|
would: ContinuityEffect;
|
|
@@ -89,7 +94,8 @@ export interface ContinuityOptions {
|
|
|
89
94
|
expectPolicySha256?: string;
|
|
90
95
|
now?: number;
|
|
91
96
|
detail?: string; /** 1.13.0: the result of verifyLockBaseline, named in record.baseline.trust. */
|
|
92
|
-
baselineTrust?: BaselineTrust["trust"];
|
|
97
|
+
baselineTrust?: BaselineTrust["trust"]; /** 1.14.0: "lk-v1" compares on the v1 hashes even when the lockfile carries jcs_v1 (default: jcs-v1 when present). */
|
|
98
|
+
digest?: "lk-v1" | "jcs-v1";
|
|
93
99
|
}
|
|
94
100
|
export declare const CONTINUITY_DEFAULT_POLICY: {
|
|
95
101
|
v: 1;
|
|
@@ -113,6 +119,34 @@ export declare const lockToolParts: (t: McpTool) => Promise<{
|
|
|
113
119
|
schema_sha256: string;
|
|
114
120
|
annotations_sha256: string;
|
|
115
121
|
}>;
|
|
122
|
+
export interface JcsV1 {
|
|
123
|
+
alg: "jcs-v1";
|
|
124
|
+
spec: string;
|
|
125
|
+
count?: number;
|
|
126
|
+
tools_sha256: string | null;
|
|
127
|
+
extras_sha256?: string;
|
|
128
|
+
tools?: {
|
|
129
|
+
name: string;
|
|
130
|
+
contract: string;
|
|
131
|
+
extras: string;
|
|
132
|
+
}[];
|
|
133
|
+
reason?: string;
|
|
134
|
+
}
|
|
135
|
+
/** jcs-v1 contract and extras digests of a tools/list result (every page; no cap). Equals lock.jcs_v1 from /api/v1/mcp/lock. */
|
|
136
|
+
export declare const jcsV1: (tools: McpTool[], opts?: {
|
|
137
|
+
partial?: boolean;
|
|
138
|
+
}) => Promise<JcsV1>;
|
|
139
|
+
/** RFC 8785 canonical JSON. */
|
|
140
|
+
export declare const jcsCanon: (v: unknown) => string;
|
|
141
|
+
export declare const jcsContract: (t: McpTool) => {
|
|
142
|
+
name: string;
|
|
143
|
+
description: string;
|
|
144
|
+
inputSchema: unknown;
|
|
145
|
+
outputSchema?: unknown;
|
|
146
|
+
};
|
|
147
|
+
export declare const jcsExtras: (t: McpTool) => Record<string, unknown>;
|
|
148
|
+
/** The most tools a v1 lockfile hashes; a longer list is compared on that many and marked incomplete. */
|
|
149
|
+
export declare const LOCK_V1_MAX_TOOLS: number;
|
|
116
150
|
export declare const lockParts: (tools: McpTool[]) => Promise<Record<string, {
|
|
117
151
|
description_sha256: string;
|
|
118
152
|
schema_sha256: string;
|
|
@@ -437,16 +471,26 @@ export interface McpLock {
|
|
|
437
471
|
[k: string]: unknown;
|
|
438
472
|
}
|
|
439
473
|
export interface McpLockCheck {
|
|
440
|
-
verdict: "unchanged" | "changed";
|
|
474
|
+
verdict: "unchanged" | "changed" | "incomplete";
|
|
441
475
|
decision: "connect" | "block";
|
|
442
476
|
approved_tools_sha256: string;
|
|
443
477
|
current_tools_sha256: string;
|
|
444
478
|
added: string[];
|
|
445
479
|
removed: string[];
|
|
446
|
-
modified: string[];
|
|
480
|
+
modified: string[]; /** 1.14.0: the jcs-v1 comparison, when the lockfile carries jcs_v1. */
|
|
481
|
+
jcs_v1?: {
|
|
482
|
+
alg: "jcs-v1";
|
|
483
|
+
current_tools_sha256: string | null;
|
|
484
|
+
current_extras_sha256: string | null;
|
|
485
|
+
approved_tools_sha256?: string;
|
|
486
|
+
contract_unchanged?: boolean;
|
|
487
|
+
extras_unchanged?: boolean;
|
|
488
|
+
} | null; /** 1.14.0: more tools than a v1 lockfile hashes were offered. */
|
|
489
|
+
offered?: number;
|
|
447
490
|
}
|
|
448
491
|
export declare function lockToolHash(t: McpTool): Promise<string>;
|
|
449
492
|
/** Hash a tools/list result the way a lockfile does: per tool, and the sorted per-tool hashes joined by \n. No network, no key. */
|
|
493
|
+
/** 1.14.0: also jcs_v1 (the jcs-v1 digests over every tool), and complete: false with offered when the list is longer than a v1 lockfile hashes (LOCK_V1_MAX_TOOLS). */
|
|
450
494
|
export declare function lockFromTools(tools: McpTool[]): Promise<{
|
|
451
495
|
tools: {
|
|
452
496
|
name: string;
|
|
@@ -454,6 +498,9 @@ export declare function lockFromTools(tools: McpTool[]): Promise<{
|
|
|
454
498
|
}[];
|
|
455
499
|
tools_sha256: string;
|
|
456
500
|
count: number;
|
|
501
|
+
complete: boolean;
|
|
502
|
+
offered?: number;
|
|
503
|
+
jcs_v1: JcsV1;
|
|
457
504
|
}>;
|
|
458
505
|
/** Compare a lockfile with the tools a server lists now. Anything added, removed or changed means "block" until a person re-approves. Offline. */
|
|
459
506
|
export declare function checkLock(lock: McpLock, tools: McpTool[]): Promise<McpLockCheck>;
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as M from "./mcp.js";
|
|
|
5
5
|
import * as R from "./revocations.js";
|
|
6
6
|
import * as C from "./continuity.js";
|
|
7
7
|
import * as F from "./frameworks.js";
|
|
8
|
+
import * as J from "./jcs.js";
|
|
8
9
|
export const CONTINUITY_DEFAULT_POLICY = C.CONTINUITY_DEFAULT_POLICY;
|
|
9
10
|
export const CONTINUITY_CHANGE_CLASSES = C.CONTINUITY_CHANGE_CLASSES;
|
|
10
11
|
export const CONTINUITY_FAILURE_CLASSES = C.CONTINUITY_FAILURE_CLASSES;
|
|
@@ -16,6 +17,14 @@ export const continuityFailure = C.continuityFailure;
|
|
|
16
17
|
export const continuityPolicy = C.continuityPolicy;
|
|
17
18
|
/** Per-part hashes (description, schema, annotations) so a change can be named. Store lockParts(tools) as lock.parts. */
|
|
18
19
|
export const lockToolParts = C.lockToolParts;
|
|
20
|
+
/** jcs-v1 contract and extras digests of a tools/list result (every page; no cap). Equals lock.jcs_v1 from /api/v1/mcp/lock. */
|
|
21
|
+
export const jcsV1 = J.jcsV1;
|
|
22
|
+
/** RFC 8785 canonical JSON. */
|
|
23
|
+
export const jcsCanon = J.jcsCanon;
|
|
24
|
+
export const jcsContract = J.jcsContract;
|
|
25
|
+
export const jcsExtras = J.jcsExtras;
|
|
26
|
+
/** The most tools a v1 lockfile hashes; a longer list is compared on that many and marked incomplete. */
|
|
27
|
+
export const LOCK_V1_MAX_TOOLS = C.LOCK_V1_MAX_TOOLS;
|
|
19
28
|
export const lockParts = C.lockParts;
|
|
20
29
|
/** The separate acceptance questions for one decision receipt. Act on policy_result, never on integrity_valid alone. */
|
|
21
30
|
/** Which continuity failure class a refused guard() result is. */
|
|
@@ -93,12 +102,16 @@ export async function lockToolHash(t) {
|
|
|
93
102
|
return sha256Hex(V.canon({ name: t?.name ?? null, description: t?.description ?? null, inputSchema: t?.inputSchema ?? t?.input_schema ?? null, annotations: t?.annotations ?? null }));
|
|
94
103
|
}
|
|
95
104
|
/** Hash a tools/list result the way a lockfile does: per tool, and the sorted per-tool hashes joined by \n. No network, no key. */
|
|
105
|
+
/** 1.14.0: also jcs_v1 (the jcs-v1 digests over every tool), and complete: false with offered when the list is longer than a v1 lockfile hashes (LOCK_V1_MAX_TOOLS). */
|
|
96
106
|
export async function lockFromTools(tools) {
|
|
97
|
-
const rows = [];
|
|
98
|
-
for (const t of
|
|
107
|
+
const rows = [], all = tools ?? [];
|
|
108
|
+
for (const t of all.slice(0, LOCK_V1_MAX_TOOLS))
|
|
99
109
|
rows.push({ name: String(t?.name ?? "").slice(0, 120), sha256: await lockToolHash(t) });
|
|
100
110
|
rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.sha256 < b.sha256 ? -1 : 1));
|
|
101
|
-
|
|
111
|
+
const out = { tools: rows, tools_sha256: await sha256Hex(rows.map((r) => r.sha256).sort().join("\n")), count: rows.length, complete: all.length <= LOCK_V1_MAX_TOOLS, jcs_v1: await jcsV1(all) };
|
|
112
|
+
if (!out.complete)
|
|
113
|
+
out.offered = all.length;
|
|
114
|
+
return out;
|
|
102
115
|
}
|
|
103
116
|
/** Compare a lockfile with the tools a server lists now. Anything added, removed or changed means "block" until a person re-approves. Offline. */
|
|
104
117
|
export async function checkLock(lock, tools) {
|
|
@@ -109,7 +122,16 @@ export async function checkLock(lock, tools) {
|
|
|
109
122
|
now.tools.forEach((r) => { cur[r.name] = r.sha256; });
|
|
110
123
|
const added = Object.keys(cur).filter((n) => !(n in was)), removed = Object.keys(was).filter((n) => !(n in cur)), modified = Object.keys(cur).filter((n) => n in was && was[n] !== cur[n]);
|
|
111
124
|
const same = lock?.tools_sha256 === now.tools_sha256 && !added.length && !removed.length && !modified.length;
|
|
112
|
-
|
|
125
|
+
const a = lock?.jcs_v1, b = now.jcs_v1;
|
|
126
|
+
const jc = { alg: "jcs-v1", current_tools_sha256: b.tools_sha256 ?? null, current_extras_sha256: b.tools_sha256 === null ? null : b.extras_sha256 ?? null };
|
|
127
|
+
if (a && typeof a.tools_sha256 === "string" && a.tools_sha256) {
|
|
128
|
+
jc.approved_tools_sha256 = a.tools_sha256;
|
|
129
|
+
jc.contract_unchanged = !!b.tools_sha256 && a.tools_sha256 === b.tools_sha256;
|
|
130
|
+
jc.extras_unchanged = (a.extras_sha256 || null) === (b.extras_sha256 || null);
|
|
131
|
+
}
|
|
132
|
+
if (!now.complete)
|
|
133
|
+
return { verdict: "incomplete", decision: "block", approved_tools_sha256: lock?.tools_sha256 ?? "", current_tools_sha256: now.tools_sha256, added, removed: [], modified, jcs_v1: jc, offered: now.offered }; // never connect on part of a list
|
|
134
|
+
return { verdict: same ? "unchanged" : "changed", decision: same ? "connect" : "block", approved_tools_sha256: lock?.tools_sha256 ?? "", current_tools_sha256: now.tools_sha256, added, removed, modified, jcs_v1: jc };
|
|
113
135
|
}
|
|
114
136
|
/** The operation for calling an MCP tool: its name, the sha256 of its definition (as in the lockfile) and the scopes you will use. */
|
|
115
137
|
export async function toolOperation(tool, scopes) { const o = { tool: String(tool?.name ?? ""), tool_schema_sha256: await lockToolHash(tool) }; if (scopes && scopes.length)
|
package/dist/jcs.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const JCS_V1_SPEC = "https://crawlcheck.io/spec/mcp-lockfile#jcs-v1";
|
|
2
|
+
/** RFC 8785 canonical JSON (keys in UTF-16 code-unit order, ECMAScript number form, no whitespace). Throws on a non-finite number. */
|
|
3
|
+
export declare function jcsCanon(v: any): any;
|
|
4
|
+
/** A tool's contract: what the model reads and the call takes or returns. */
|
|
5
|
+
export declare function jcsContract(t: any): {
|
|
6
|
+
name: any;
|
|
7
|
+
description: any;
|
|
8
|
+
inputSchema: any;
|
|
9
|
+
};
|
|
10
|
+
/** A tool's extras: every member beyond name, description, inputSchema and outputSchema. */
|
|
11
|
+
export declare function jcsExtras(t: any): any;
|
|
12
|
+
/**
|
|
13
|
+
* The jcs-v1 digests of a tools/list result (every page). opts.partial: true when a page was not read (the list is then
|
|
14
|
+
* not digested). Returns { alg, spec, count, tools_sha256, extras_sha256, tools: [{name, contract, extras}] } or
|
|
15
|
+
* { alg, spec, tools_sha256: null, reason }.
|
|
16
|
+
*/
|
|
17
|
+
export declare function jcsV1(tools: any, opts?: {}): Promise<{
|
|
18
|
+
alg: string;
|
|
19
|
+
spec: string;
|
|
20
|
+
tools_sha256: null;
|
|
21
|
+
reason: string;
|
|
22
|
+
count?: undefined;
|
|
23
|
+
extras_sha256?: undefined;
|
|
24
|
+
tools?: undefined;
|
|
25
|
+
} | {
|
|
26
|
+
reason?: undefined;
|
|
27
|
+
alg: string;
|
|
28
|
+
spec: string;
|
|
29
|
+
count: number;
|
|
30
|
+
tools_sha256: string;
|
|
31
|
+
extras_sha256: string;
|
|
32
|
+
tools: {
|
|
33
|
+
name: any;
|
|
34
|
+
contract: string;
|
|
35
|
+
extras: string;
|
|
36
|
+
}[];
|
|
37
|
+
}>;
|
package/dist/jcs.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// jcs-v1 tool-list digests (https://crawlcheck.io/spec/mcp-lockfile#jcs-v1), 1.14.0. The same construction the lockfile
|
|
2
|
+
// endpoint publishes as lock.jcs_v1, step for step, so a digest computed here equals the signed one byte for byte.
|
|
3
|
+
//
|
|
4
|
+
// contract {name, description, inputSchema, outputSchema}: description "" when absent or not a string, inputSchema {}
|
|
5
|
+
// when absent or not an object, outputSchema only when the server sent an object. RFC 8785, SHA-256 (hex).
|
|
6
|
+
// extras every other member (title, annotations, icons, execution, _meta, vendor members), strings cleaned (NUL and
|
|
7
|
+
// unpaired surrogates -> U+FFFD), nesting past 12 levels -> "…". RFC 8785, SHA-256 (hex).
|
|
8
|
+
// tools_sha256 SRI sha256 over the RFC 8785 array of contracts sorted by name (UTF-16 code units; ties by bytes).
|
|
9
|
+
// extras_sha256 SRI sha256 over {"tools": {name: extras}} for the tools that carry extras ("" when none do), with
|
|
10
|
+
// "oversized" (one tool's extras > 16,384 bytes) and "omitted" (past 262,144 bytes in all).
|
|
11
|
+
// A tool with no name, or a number with no IEEE 754 double, makes the list not digestible: tools_sha256 null and a reason.
|
|
12
|
+
// No list is cut: every tool given is digested and listed.
|
|
13
|
+
const enc = new TextEncoder();
|
|
14
|
+
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
15
|
+
const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
16
|
+
const b64 = (u8) => { let s = ""; for (let i = 0; i < u8.length; i++)
|
|
17
|
+
s += String.fromCharCode(u8[i]); return btoa(s); };
|
|
18
|
+
const sha = async (bytes) => new Uint8Array(await subtle.digest("SHA-256", bytes));
|
|
19
|
+
export const JCS_V1_SPEC = "https://crawlcheck.io/spec/mcp-lockfile#jcs-v1";
|
|
20
|
+
/** RFC 8785 canonical JSON (keys in UTF-16 code-unit order, ECMAScript number form, no whitespace). Throws on a non-finite number. */
|
|
21
|
+
export function jcsCanon(v) {
|
|
22
|
+
if (v === null || typeof v === "boolean" || typeof v === "string")
|
|
23
|
+
return JSON.stringify(v);
|
|
24
|
+
if (typeof v === "number") {
|
|
25
|
+
if (!isFinite(v))
|
|
26
|
+
throw new Error("a number with no IEEE 754 double");
|
|
27
|
+
return JSON.stringify(v);
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(v))
|
|
30
|
+
return "[" + v.map(jcsCanon).join(",") + "]";
|
|
31
|
+
if (typeof v === "object")
|
|
32
|
+
return "{" + Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => JSON.stringify(k) + ":" + jcsCanon(v[k])).join(",") + "}";
|
|
33
|
+
throw new Error("not a JSON value");
|
|
34
|
+
}
|
|
35
|
+
const isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
36
|
+
/** A tool's contract: what the model reads and the call takes or returns. */
|
|
37
|
+
export function jcsContract(t) {
|
|
38
|
+
const o = { name: t.name, description: typeof t.description === "string" ? t.description : "", inputSchema: isObj(t.inputSchema) ? t.inputSchema : {} };
|
|
39
|
+
if (isObj(t.outputSchema))
|
|
40
|
+
o.outputSchema = t.outputSchema;
|
|
41
|
+
return o;
|
|
42
|
+
}
|
|
43
|
+
const cleanS = (s) => String(s).replace(/\u0000/g, "�").replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
|
|
44
|
+
function clean(v, d) {
|
|
45
|
+
if (d > 12)
|
|
46
|
+
return "…";
|
|
47
|
+
if (typeof v === "string")
|
|
48
|
+
return cleanS(v);
|
|
49
|
+
if (Array.isArray(v))
|
|
50
|
+
return v.map((x) => clean(x, d + 1));
|
|
51
|
+
if (v && typeof v === "object") {
|
|
52
|
+
const o = {};
|
|
53
|
+
Object.keys(v).forEach((k) => { o[cleanS(k)] = clean(v[k], d + 1); });
|
|
54
|
+
return o;
|
|
55
|
+
}
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
/** A tool's extras: every member beyond name, description, inputSchema and outputSchema. */
|
|
59
|
+
export function jcsExtras(t) {
|
|
60
|
+
const o = {};
|
|
61
|
+
Object.keys(t || {}).forEach((k) => { if (k !== "name" && k !== "description" && k !== "inputSchema" && k !== "outputSchema")
|
|
62
|
+
o[k] = t[k]; });
|
|
63
|
+
return clean(o, 0);
|
|
64
|
+
}
|
|
65
|
+
const cpCmp = (a, b) => { const x = Array.from(a), y = Array.from(b), n = Math.min(x.length, y.length); for (let i = 0; i < n; i++) {
|
|
66
|
+
const c = x[i].codePointAt(0) - y[i].codePointAt(0);
|
|
67
|
+
if (c)
|
|
68
|
+
return c;
|
|
69
|
+
} return x.length - y.length; };
|
|
70
|
+
const byteCmp = (a, b) => { const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++)
|
|
71
|
+
if (a[i] !== b[i])
|
|
72
|
+
return a[i] - b[i]; return a.length - b.length; };
|
|
73
|
+
async function extrasSet(tools) {
|
|
74
|
+
const per = {};
|
|
75
|
+
for (const t of tools || []) {
|
|
76
|
+
if (!t || typeof t.name !== "string" || !t.name)
|
|
77
|
+
continue;
|
|
78
|
+
const x = jcsExtras(t);
|
|
79
|
+
if (Object.keys(x).length)
|
|
80
|
+
per[cleanS(t.name)] = x;
|
|
81
|
+
}
|
|
82
|
+
const names = Object.keys(per).sort(cpCmp);
|
|
83
|
+
if (!names.length)
|
|
84
|
+
return "";
|
|
85
|
+
const body = { tools: {} }, over = {}, omit = [];
|
|
86
|
+
let used = 0;
|
|
87
|
+
for (const n of names) {
|
|
88
|
+
const sz = enc.encode(jcsCanon(per[n])).length;
|
|
89
|
+
if (sz > 16384) {
|
|
90
|
+
over[n] = sz;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (used + sz > 262144) {
|
|
94
|
+
omit.push(n);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
body.tools[n] = per[n];
|
|
98
|
+
used += sz;
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(over).length)
|
|
101
|
+
body.oversized = over;
|
|
102
|
+
if (omit.length)
|
|
103
|
+
body.omitted = omit;
|
|
104
|
+
return "sha256-" + b64(await sha(enc.encode(jcsCanon(body))));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The jcs-v1 digests of a tools/list result (every page). opts.partial: true when a page was not read (the list is then
|
|
108
|
+
* not digested). Returns { alg, spec, count, tools_sha256, extras_sha256, tools: [{name, contract, extras}] } or
|
|
109
|
+
* { alg, spec, tools_sha256: null, reason }.
|
|
110
|
+
*/
|
|
111
|
+
export async function jcsV1(tools, opts = {}) {
|
|
112
|
+
if (opts.partial)
|
|
113
|
+
return { alg: "jcs-v1", spec: JCS_V1_SPEC, tools_sha256: null, reason: "the tool list has more pages than were read; a partial list is not digested" };
|
|
114
|
+
try {
|
|
115
|
+
const rows = [];
|
|
116
|
+
for (const t of tools || []) {
|
|
117
|
+
if (!t || typeof t.name !== "string" || !t.name)
|
|
118
|
+
return { alg: "jcs-v1", spec: JCS_V1_SPEC, tools_sha256: null, reason: "a tool has no name" };
|
|
119
|
+
const cj = jcsCanon(jcsContract(t)), cb = enc.encode(cj);
|
|
120
|
+
rows.push({ name: t.name, cj, cb, contract: hex(await sha(cb)), extras: hex(await sha(enc.encode(jcsCanon(jcsExtras(t))))) });
|
|
121
|
+
}
|
|
122
|
+
rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : byteCmp(a.cb, b.cb)));
|
|
123
|
+
return { alg: "jcs-v1", spec: JCS_V1_SPEC, count: rows.length,
|
|
124
|
+
tools_sha256: "sha256-" + b64(await sha(enc.encode("[" + rows.map((r) => r.cj).join(",") + "]"))),
|
|
125
|
+
extras_sha256: await extrasSet(tools),
|
|
126
|
+
tools: rows.map((r) => ({ name: r.name, contract: r.contract, extras: r.extras })) };
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
return { alg: "jcs-v1", spec: JCS_V1_SPEC, tools_sha256: null, reason: String((e && e.message) || e).slice(0, 120) };
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// request is never sent.
|
|
18
18
|
import { normalizeDomain } from "./index.js";
|
|
19
19
|
import { checkLock, toolOperation } from "./index.js";
|
|
20
|
+
/** The most tools/list pages read before the list is called incomplete (tool_list_truncated). */
|
|
21
|
+
const MAX_TOOL_PAGES = 1000;
|
|
20
22
|
import { continuityCheck, continuityFailure, continuityClassOf, verifyLockBaseline, baselineRefusal } from "./continuity.js";
|
|
21
23
|
export class McpPreflightRefused extends Error {
|
|
22
24
|
constructor(stage, why, detail) { super("MCP preflight refused " + stage + ": " + why); this.name = "McpPreflightRefused"; this.stage = stage; this.why = why; this.receipt = detail && detail.receipt || null; this.lock = detail && detail.lock || null; this.verification = detail && detail.verification || null; }
|
|
@@ -106,11 +108,8 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
106
108
|
// vantage and the signed receipt names the endpoint and tool state it covered (a changed list comes back as block).
|
|
107
109
|
const mcpScope = function () { if (!state.url || !/^https:/i.test(state.url))
|
|
108
110
|
return undefined; const h = opts.lock && typeof opts.lock.tools_sha256 === "string" ? opts.lock.tools_sha256 : null; return h ? { url: state.url, tools_sha256: h } : { url: state.url }; };
|
|
109
|
-
const
|
|
111
|
+
const checkConnect = async function (host) {
|
|
110
112
|
const g = await cc.guard(host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys, mcp: mcpScope() });
|
|
111
|
-
state.receipt = g.receipt;
|
|
112
|
-
state.verification = g.verification;
|
|
113
|
-
state.decidedAt = Date.now();
|
|
114
113
|
if (!g.proceed && CO) {
|
|
115
114
|
const rec = await keep(await continuityFailure(continuityClassOf(g), coOpts({ detail: (g.why || []).join("; ").slice(0, 300) })));
|
|
116
115
|
if (rec.outcome === "proceed")
|
|
@@ -136,6 +135,19 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
136
135
|
}
|
|
137
136
|
return g;
|
|
138
137
|
};
|
|
138
|
+
const decide = async function (host) {
|
|
139
|
+
const url = state.url;
|
|
140
|
+
state.receipt = null;
|
|
141
|
+
state.verification = null;
|
|
142
|
+
const result = await checkConnect(host);
|
|
143
|
+
if (state.url !== url)
|
|
144
|
+
throw new McpPreflightRefused("connect", "the server URL changed during preflight");
|
|
145
|
+
// A refusal may include a valid-looking receipt. Only a completed gate can cache it.
|
|
146
|
+
state.receipt = result.receipt;
|
|
147
|
+
state.verification = result.verification;
|
|
148
|
+
state.decidedAt = Date.now();
|
|
149
|
+
return result;
|
|
150
|
+
};
|
|
139
151
|
return {
|
|
140
152
|
state,
|
|
141
153
|
/** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
|
|
@@ -186,6 +198,8 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
186
198
|
state.approved = null;
|
|
187
199
|
return null;
|
|
188
200
|
}
|
|
201
|
+
if (meta && meta.nextCursor)
|
|
202
|
+
throw new McpPreflightRefused("connect", "the server's tool list could not be read in full (a further page was not read); nothing is approved on part of a list", { receipt: state.receipt });
|
|
189
203
|
const lc = await checkLock(baseLock, tools || []);
|
|
190
204
|
state.lockCheck = lc;
|
|
191
205
|
state.approved = new Set((opts.lock.tools || []).map(function (t) { return t.name; }));
|
|
@@ -247,12 +261,27 @@ export function withPreflight(client, cc, opts = {}) {
|
|
|
247
261
|
if (opts.lock || opts.confirmDestructive || (opts.toolReceipts || "write") !== "none") {
|
|
248
262
|
let list = [];
|
|
249
263
|
let meta = null, bad = false;
|
|
250
|
-
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
try { // 1.14.0: every page (nextCursor followed); a list whose pages could not all be read is marked incomplete
|
|
265
|
+
if (typeof client.listTools === "function") {
|
|
266
|
+
let cursor = null;
|
|
267
|
+
const seen = new Set();
|
|
268
|
+
for (;;) {
|
|
269
|
+
const t = await client.listTools(cursor ? { cursor } : undefined);
|
|
270
|
+
if (!t)
|
|
271
|
+
break;
|
|
272
|
+
if (!Array.isArray(t.tools)) {
|
|
273
|
+
bad = true;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
list = list.concat(t.tools);
|
|
277
|
+
const next = t.nextCursor || null;
|
|
278
|
+
meta = { nextCursor: next };
|
|
279
|
+
if (!next || seen.has(next) || seen.size >= MAX_TOOL_PAGES - 1)
|
|
280
|
+
break;
|
|
281
|
+
seen.add(next);
|
|
282
|
+
cursor = next;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
256
285
|
}
|
|
257
286
|
catch (e) {
|
|
258
287
|
list = [];
|