@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 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
@@ -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. */
@@ -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 ?? []).slice(0, 300))
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, 300))
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: opts.complete !== false };
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 (opts.complete === false)
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 (opts.complete !== false)
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
- tools_sha256: string;
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 (tools ?? []).slice(0, 300))
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
- return { tools: rows, tools_sha256: await sha256Hex(rows.map((r) => r.sha256).sort().join("\n")), count: rows.length };
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
- return { verdict: same ? "unchanged" : "changed", decision: same ? "connect" : "block", approved_tools_sha256: lock?.tools_sha256 ?? "", current_tools_sha256: now.tools_sha256, added, removed, modified };
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 decide = async function (host) {
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
- const t = typeof client.listTools === "function" ? await client.listTools() : null;
252
- list = (t && t.tools) || [];
253
- meta = t ? { nextCursor: t.nextCursor || null } : null;
254
- if (t && !Array.isArray(t.tools))
255
- bad = true;
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 = [];