@crawlcheck/sdk 1.0.8 → 1.1.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
@@ -50,6 +50,25 @@ v.decisions.transact; // allow | warn | require_confirmation | block | unsuppor
50
50
 
51
51
  `stapleCheck(record, { host, publishedKids })` does the offline check on its own. Spec: https://crawlcheck.io/spec/staple
52
52
 
53
+ ## Preflight inside your MCP client
54
+
55
+ Wrap the official MCP client so `connect` and `callTool` are decided before anything is sent ([proposal](https://crawlcheck.io/spec/mcp-preflight)):
56
+
57
+ ```js
58
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
59
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
60
+ import { CrawlCheck, withPreflight, McpPreflightRefused } from "@crawlcheck/sdk";
61
+
62
+ const client = withPreflight(new Client({ name: "my-agent", version: "1.0.0" }), new CrawlCheck(), {
63
+ confirm: async (receipt) => askTheUser(receipt), // require_confirmation, and destructive tools with confirmDestructive
64
+ lock: approvedLock, // optional: refuse a server whose tools changed, and any tool outside the lock
65
+ });
66
+ try { await client.connect(new StreamableHTTPClientTransport(new URL(url))); }
67
+ catch (e) { if (e instanceof McpPreflightRefused) console.log(e.stage, e.why, e.receipt.decision); else throw e; }
68
+ ```
69
+
70
+ `block`, `unsupported`, an unverifiable receipt, or `require_confirmation` with no yes refuse the connect before `initialize`. Other client stacks use the bare hooks: `mcpPreflight(cc, opts)` returns `beforeConnect`, `afterConnect` and `beforeToolCall`. A runnable demo is in `examples/mcp-preflight-demo.mjs`.
71
+
53
72
  ## Do not trust the API: check the evidence yourself
54
73
 
55
74
  ```js
package/dist/index.d.ts CHANGED
@@ -195,7 +195,8 @@ export interface PreflightPolicy {
195
195
  export interface PreflightOptions {
196
196
  agent?: string;
197
197
  template?: string;
198
- policy?: PreflightPolicy;
198
+ policy?: PreflightPolicy; /** 8-128 characters of A-Z a-z 0-9 . _ : - signed into the receipt (replay binding). guard() sends a fresh one when you give none. */
199
+ nonce?: string;
199
200
  }
200
201
  /** A signed crawlcheck-decision receipt. Verify it with verifyDocument (or let guard() do it). */
201
202
  export interface DecisionReceipt {
@@ -220,8 +221,60 @@ export interface GuardOptions extends PreflightOptions {
220
221
  allowWarn?: boolean;
221
222
  /** Called on "require_confirmation"; proceed only if it resolves true. Without it, require_confirmation does not proceed. */
222
223
  confirm?: (receipt: DecisionReceipt) => boolean | Promise<boolean>;
223
- /** Also require the signing key to be in the published key directory (default true). */
224
+ /** Read the published key directory (default true). With false, pass publishedKids: a guard never accepts a receipt without checking who signed it. */
224
225
  onlineKeys?: boolean;
226
+ /** Pinned CrawlCheck key ids, for offline use with onlineKeys: false. */
227
+ publishedKids?: string[];
228
+ /** Testing clock (ms since epoch). Default Date.now(). */
229
+ now?: number;
230
+ }
231
+ /** The longest a receipt may stay valid, per action (seconds). A receipt that claims longer is refused. Published at https://crawlcheck.io/spec/guard */
232
+ export declare const ACTION_MAX_LIFETIME_S: Record<string, number>;
233
+ /** The most clock skew a receipt may claim (seconds). */
234
+ export declare const MAX_CLOCK_SKEW_S = 300;
235
+ export interface DecisionWant {
236
+ domain: string;
237
+ action: Action | string;
238
+ nonce?: string | null;
239
+ now?: number;
240
+ }
241
+ /**
242
+ * Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
243
+ * time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
244
+ * verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
245
+ */
246
+ export declare function checkDecision(receipt: unknown, want: DecisionWant): {
247
+ ok: boolean;
248
+ why: string[];
249
+ };
250
+ export interface GuardResult {
251
+ proceed: boolean;
252
+ receipt: DecisionReceipt | null;
253
+ verification: (DocumentVerification & Trust) | null;
254
+ header: Record<string, string> | null;
255
+ why: string[];
256
+ split_view?: LogFork[];
257
+ }
258
+ export interface GuardedFetchOptions extends GuardOptions {
259
+ action?: Action;
260
+ init?: RequestInit;
261
+ maxHops?: number;
262
+ fetch?: typeof fetch;
263
+ }
264
+ export interface GuardedHop {
265
+ url: string;
266
+ domain: string;
267
+ proceed: boolean;
268
+ decision: string | null;
269
+ why: string[];
270
+ status?: number;
271
+ }
272
+ export interface GuardedFetchResult {
273
+ proceed: boolean;
274
+ response: Response | null;
275
+ url: string;
276
+ hops: GuardedHop[];
277
+ why: string[];
225
278
  }
226
279
  export interface LogHead {
227
280
  kind: "crawlcheck-log-sth";
@@ -356,6 +409,71 @@ export declare const stapleCheck: (record: string, opts: {
356
409
  publishedKids: string[];
357
410
  now?: number;
358
411
  }) => Promise<StapleCheck>;
412
+ export interface McpPreflightOptions extends PreflightOptions {
413
+ /** Proceed on warn too (default false). */
414
+ allowWarn?: boolean;
415
+ /** Called on require_confirmation (and, with confirmDestructive, before a destructive tool call); the connect or call proceeds only if it resolves true. */
416
+ confirm?: (receipt: DecisionReceipt & {
417
+ tool?: string;
418
+ annotations?: unknown;
419
+ }) => boolean | Promise<boolean>;
420
+ /** An approved lockfile (mcpLock / lockFromTools): the tools the server lists after connecting must match it, and only tools in it may be called. */
421
+ lock?: McpLock;
422
+ /** Ask confirm() before a tool annotated destructive (destructiveHint true, or readOnlyHint false with openWorldHint true). */
423
+ confirmDestructive?: boolean;
424
+ /** Re-decide the host when the cached connect receipt is older than this (default: until its expires_at). */
425
+ maxAgeMs?: number;
426
+ /** A local (stdio) server has no host to resolve: "allow" (default) or "refuse". */
427
+ local?: "allow" | "refuse";
428
+ /** The server URL when the transport does not expose it. */
429
+ serverUrl?: string;
430
+ onlineKeys?: boolean;
431
+ }
432
+ export interface McpPreflightState {
433
+ url: string | null;
434
+ host: string | null;
435
+ receipt: DecisionReceipt | null;
436
+ verification: (DocumentVerification & Trust) | null;
437
+ decidedAt: number;
438
+ lockCheck: McpLockCheck | null;
439
+ approved: Set<string> | null;
440
+ }
441
+ export interface McpPreflightHooks {
442
+ state: McpPreflightState;
443
+ beforeConnect(url: string | null | undefined): Promise<{
444
+ local: boolean;
445
+ host?: string;
446
+ receipt: DecisionReceipt | null;
447
+ verification?: DocumentVerification & Trust;
448
+ }>;
449
+ afterConnect(tools: McpTool[]): Promise<McpLockCheck | null>;
450
+ beforeToolCall(name: string, annotations?: unknown): Promise<{
451
+ receipt: DecisionReceipt | null;
452
+ }>;
453
+ }
454
+ /** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
455
+ export declare const McpPreflightRefused: new (stage: "connect" | "tools/call", why: string, detail?: {
456
+ receipt?: DecisionReceipt | null;
457
+ lock?: McpLockCheck | null;
458
+ verification?: unknown;
459
+ }) => Error & {
460
+ stage: "connect" | "tools/call";
461
+ why: string;
462
+ receipt: DecisionReceipt | null;
463
+ lock: McpLockCheck | null;
464
+ verification: unknown;
465
+ };
466
+ /** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
467
+ export declare const mcpPreflight: (cc: CrawlCheck, opts?: McpPreflightOptions) => McpPreflightHooks;
468
+ /** Wrap an MCP client (the official @modelcontextprotocol/sdk Client, or anything with connect(transport) and callTool(params)) so both run the preflight first. Returns the same client with client.preflight set. */
469
+ export declare const withPreflight: <C extends {
470
+ connect: (...a: any[]) => Promise<any>;
471
+ callTool: (...a: any[]) => Promise<any>;
472
+ }>(client: C, cc: CrawlCheck, opts?: McpPreflightOptions) => C & {
473
+ preflight: McpPreflightHooks;
474
+ };
475
+ /** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
476
+ export declare const transportUrl: (transport: unknown) => string | null;
359
477
  export interface ClientOptions {
360
478
  /** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
361
479
  key?: string;
@@ -434,14 +552,20 @@ export declare class CrawlCheck {
434
552
  preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
435
553
  /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
436
554
  verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification & Trust>;
437
- /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
438
- guard(domain: string, action?: Action, opts?: GuardOptions): Promise<{
439
- proceed: boolean;
440
- receipt: DecisionReceipt;
441
- verification: DocumentVerification & Trust;
442
- header: Record<string, string> | null;
443
- split_view?: LogFork[];
444
- }>;
555
+ /**
556
+ * Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
557
+ * require_confirmation only if confirm() says yes; block, unsupported and every failure never (fail closed: an error,
558
+ * an unreachable CrawlCheck, a malformed, foreign, expired, replayed or out-of-scope receipt all return proceed false
559
+ * with why[]). Passes the acceptance suite at https://crawlcheck.io/spec/guard
560
+ */
561
+ guard(domain: string, action?: Action, opts?: GuardOptions): Promise<GuardResult>;
562
+ /**
563
+ * fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
564
+ * to a new domain. The site is contacted only after its domain proceeds; a refused hop is never requested. Redirects
565
+ * are followed by hand (GET/HEAD only), to http(s) only, at most maxHops (10). The receipt header is sent only to the
566
+ * domain it names. In a browser a cross-origin redirect hides its target, so it is refused rather than followed blind.
567
+ */
568
+ guardedFetch(url: string, opts?: GuardedFetchOptions): Promise<GuardedFetchResult>;
445
569
  /** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
446
570
  /** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
447
571
  mcpLock(url: string): Promise<McpLock & JsonObject>;
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as V from "./verify.js";
2
2
  import * as L from "./log.js";
3
3
  import * as S from "./staple.js";
4
+ import * as M from "./mcp.js";
4
5
  function withTrust(v, kid, published) {
5
6
  let checks = v.checks;
6
7
  let issuer = null;
@@ -37,6 +38,7 @@ export function normalizeDomain(v) {
37
38
  }
38
39
  /** { "CrawlCheck-Receipt": "v=1; sha256=..." } for a decision receipt. Send it only to the domain the receipt names, before expires_at. */
39
40
  export function receiptHeader(receipt) { return { [RECEIPT_HEADER]: "v=1; sha256=" + receipt.sha256 }; }
41
+ function randomNonce() { const b = new Uint8Array(16); globalThis.crypto.getRandomValues(b); return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); }
40
42
  async function sha256Hex(s) {
41
43
  const b = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
42
44
  return Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
@@ -63,6 +65,50 @@ export async function checkLock(lock, tools) {
63
65
  const same = lock?.tools_sha256 === now.tools_sha256 && !added.length && !removed.length && !modified.length;
64
66
  return { verdict: same ? "unchanged" : "changed", decision: same ? "connect" : "block", approved_tools_sha256: lock?.tools_sha256 ?? "", current_tools_sha256: now.tools_sha256, added, removed, modified };
65
67
  }
68
+ /** The longest a receipt may stay valid, per action (seconds). A receipt that claims longer is refused. Published at https://crawlcheck.io/spec/guard */
69
+ export const ACTION_MAX_LIFETIME_S = { read: 86400, cite: 86400, connect: 3600, transact: 300, administer: 300 };
70
+ /** The most clock skew a receipt may claim (seconds). */
71
+ export const MAX_CLOCK_SKEW_S = 300;
72
+ const DECISIONS = ["allow", "warn", "require_confirmation", "block", "unsupported"];
73
+ /**
74
+ * Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
75
+ * time window, per-action lifetime, known decision, resolve binding); the signature and issuer are checked by
76
+ * verifyDocument. guard() runs both. https://crawlcheck.io/spec/guard
77
+ */
78
+ export function checkDecision(receipt, want) {
79
+ const why = [], r = receipt;
80
+ if (!r || typeof r !== "object" || Array.isArray(r))
81
+ return { ok: false, why: ["no receipt: CrawlCheck sent " + (r === null || r === undefined ? "nothing" : typeof r)] };
82
+ if (r.kind !== "crawlcheck-decision" || r.v !== 1)
83
+ why.push("not a v1 decision receipt (kind " + r.kind + ", v " + r.v + ")");
84
+ const wd = normalizeDomain(want.domain);
85
+ if (!wd || normalizeDomain(r.domain) !== wd)
86
+ why.push("the receipt is for " + r.domain + ", not " + want.domain);
87
+ if (String(r.action) !== String(want.action))
88
+ why.push("the receipt is for action " + r.action + ", not " + want.action);
89
+ if (want.nonce && r.nonce !== want.nonce)
90
+ why.push(r.nonce ? "the receipt carries another request's nonce (replayed)" : "the receipt does not carry the nonce this request sent");
91
+ const now = want.now ?? Date.now(), skew = Math.min(Math.max(Number(r.clock_skew_s) || 0, 0), MAX_CLOCK_SKEW_S) * 1000;
92
+ const at = Date.parse(r.decided_at), exp = Date.parse(r.expires_at);
93
+ if (!isFinite(at) || !isFinite(exp))
94
+ why.push("decided_at or expires_at missing");
95
+ else {
96
+ if (now > exp + skew)
97
+ why.push("expired at " + r.expires_at);
98
+ if (at > now + skew)
99
+ why.push("decided in the future (" + r.decided_at + "): check the clock");
100
+ const max = ACTION_MAX_LIFETIME_S[String(want.action)];
101
+ if (max === undefined)
102
+ why.push("unknown action " + want.action);
103
+ else if (exp - at > max * 1000 + 1000)
104
+ why.push("valid for " + Math.round((exp - at) / 1000) + " s; a " + want.action + " receipt may last at most " + max + " s");
105
+ }
106
+ if (DECISIONS.indexOf(r.decision) < 0)
107
+ why.push("unknown decision " + r.decision);
108
+ if (!r.resolve || !/^[0-9a-f]{64}$/.test(String(r.resolve.sha256 || "")))
109
+ why.push("the receipt names no resolve answer");
110
+ return { ok: why.length === 0, why };
111
+ }
66
112
  export const LOG_ID = L.LOG_ID;
67
113
  /** A new, empty transparency-log store (plain JSON: persist it between runs). */
68
114
  export const newLogStore = L.newLogStore;
@@ -83,6 +129,14 @@ export const parseStaple = S.parseStaple;
83
129
  export const stapleFromResponse = S.stapleFromResponse;
84
130
  /** Verify a staple offline: published key, signature, the record names opts.host (or a parent), fresh_until in the future. Expired or foreign staples are never valid. */
85
131
  export const stapleCheck = S.stapleCheck;
132
+ /** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
133
+ export const McpPreflightRefused = M.McpPreflightRefused;
134
+ /** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
135
+ export const mcpPreflight = M.mcpPreflight;
136
+ /** Wrap an MCP client (the official @modelcontextprotocol/sdk Client, or anything with connect(transport) and callTool(params)) so both run the preflight first. Returns the same client with client.preflight set. */
137
+ export const withPreflight = M.withPreflight;
138
+ /** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
139
+ export const transportUrl = M.transportUrl;
86
140
  export class CrawlCheckError extends Error {
87
141
  status;
88
142
  body;
@@ -190,29 +244,122 @@ export class CrawlCheck {
190
244
  b.template = opts.template;
191
245
  if (opts.policy)
192
246
  b.policy = opts.policy;
247
+ if (opts.nonce)
248
+ b.nonce = opts.nonce;
193
249
  return this.post("/api/v1/preflight", b);
194
250
  }
195
251
  /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
196
252
  async verifyDocument(doc, onlineKeys = true) {
197
253
  return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null);
198
254
  }
199
- /** Preflight, verify the receipt, and decide. Proceeds on allow; warn only with allowWarn; require_confirmation only if confirm() says yes; block and unsupported never. */
255
+ /**
256
+ * Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
257
+ * require_confirmation only if confirm() says yes; block, unsupported and every failure never (fail closed: an error,
258
+ * an unreachable CrawlCheck, a malformed, foreign, expired, replayed or out-of-scope receipt all return proceed false
259
+ * with why[]). Passes the acceptance suite at https://crawlcheck.io/spec/guard
260
+ */
200
261
  async guard(domain, action = "read", opts = {}) {
201
- const receipt = await this.preflight(domain, action, opts);
202
- if (this.log.forks.length) { // the log has shown this client two contradicting histories: no answer from it is trusted for action
203
- const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
204
- return { proceed: false, receipt, verification, header: null, split_view: this.log.forks.slice() };
262
+ const nonce = opts.nonce || randomNonce();
263
+ const no = (why, receipt = null, verification = null) => ({ proceed: false, receipt, verification, header: null, why });
264
+ let receipt;
265
+ try {
266
+ receipt = await this.preflight(domain, action, Object.assign({}, opts, { nonce }));
267
+ }
268
+ catch (e) {
269
+ return no(["CrawlCheck did not give a decision: " + (e?.message || e)]);
205
270
  }
206
- const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
207
- if (!verification.accepted && (opts.onlineKeys ?? true))
208
- return { proceed: false, receipt, verification, header: null };
209
- if (!verification.verified)
210
- return { proceed: false, receipt, verification, header: null };
271
+ if (this.log.forks.length)
272
+ return Object.assign(no(["the transparency log has shown this client two histories (split view)"], receipt), { split_view: this.log.forks.slice() });
273
+ const scope = checkDecision(receipt, { domain, action, nonce, now: opts.now });
274
+ if (!receipt || typeof receipt !== "object")
275
+ return no(scope.why);
276
+ const online = opts.onlineKeys ?? true;
277
+ if (!online && !(Array.isArray(opts.publishedKids) && opts.publishedKids.length))
278
+ return no(["onlineKeys is false and no publishedKids were pinned: the signer cannot be checked"], receipt);
279
+ let verification;
280
+ try {
281
+ verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids);
282
+ }
283
+ catch (e) {
284
+ return no(["the receipt could not be verified: " + (e?.message || e)].concat(scope.why), receipt);
285
+ }
286
+ const why = scope.why.slice();
287
+ if (!verification.integrity_valid)
288
+ why.push("integrity failed: " + verification.checks.filter((c) => c.ok === false).map((c) => c.id).join(", "));
289
+ if (verification.issuer_trusted !== true)
290
+ why.push("signed by a key that is not in CrawlCheck's published key directory");
291
+ if (why.length)
292
+ return no(why, receipt, verification);
211
293
  const d = receipt.decision;
212
294
  let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
213
- if (d === "require_confirmation" && opts.confirm)
214
- proceed = !!(await opts.confirm(receipt));
215
- return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null };
295
+ if (d === "require_confirmation") {
296
+ if (opts.confirm) {
297
+ try {
298
+ proceed = (await opts.confirm(receipt)) === true;
299
+ }
300
+ catch {
301
+ proceed = false;
302
+ }
303
+ }
304
+ }
305
+ if (!proceed)
306
+ why.push(d === "warn" ? "decision is warn and allowWarn is not set" : d === "require_confirmation" ? (opts.confirm ? "a human did not confirm" : "decision is require_confirmation and no confirm() was given") : "decision is " + d);
307
+ return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null, why };
308
+ }
309
+ /**
310
+ * fetch() with the check where the action runs: guard() before the first request, and again before every redirect hop
311
+ * to a new domain. The site is contacted only after its domain proceeds; a refused hop is never requested. Redirects
312
+ * are followed by hand (GET/HEAD only), to http(s) only, at most maxHops (10). The receipt header is sent only to the
313
+ * domain it names. In a browser a cross-origin redirect hides its target, so it is refused rather than followed blind.
314
+ */
315
+ async guardedFetch(url, opts = {}) {
316
+ const action = opts.action ?? "read", max = opts.maxHops ?? 10, f = opts.fetch ? opts.fetch.bind(globalThis) : this.f;
317
+ const init = Object.assign({}, opts.init || {}), method = String(init.method || "GET").toUpperCase();
318
+ const hops = [], seen = new Map();
319
+ let cur = url;
320
+ for (let n = 0; n <= max; n++) {
321
+ let u;
322
+ try {
323
+ u = new URL(cur);
324
+ }
325
+ catch {
326
+ return { proceed: false, response: null, url: cur, hops, why: ["not a URL: " + cur] };
327
+ }
328
+ if (u.protocol !== "https:" && u.protocol !== "http:")
329
+ return { proceed: false, response: null, url: cur, hops, why: ["refused a " + u.protocol + " URL"] };
330
+ const dom = normalizeDomain(u.hostname);
331
+ if (!dom)
332
+ return { proceed: false, response: null, url: cur, hops, why: ["not a public domain name: " + u.hostname] };
333
+ let g = seen.get(dom);
334
+ if (!g) {
335
+ g = await this.guard(dom, action, opts);
336
+ seen.set(dom, g);
337
+ }
338
+ const hop = { url: cur, domain: dom, proceed: g.proceed, decision: g.receipt ? String(g.receipt.decision) : null, why: g.why };
339
+ hops.push(hop);
340
+ if (!g.proceed)
341
+ return { proceed: false, response: null, url: cur, hops, why: [dom + ": " + (g.why.join("; ") || "refused")] };
342
+ const h = new Headers(init.headers || {});
343
+ if (g.header)
344
+ for (const [k, v] of Object.entries(g.header))
345
+ h.set(k, v);
346
+ const res = await f(cur, Object.assign({}, init, { headers: h, redirect: "manual" }));
347
+ hop.status = res.status;
348
+ if (res.type === "opaqueredirect")
349
+ return { proceed: false, response: null, url: cur, hops, why: ["the runtime hid the redirect target, so it cannot be checked"] };
350
+ const loc = res.headers.get("location");
351
+ if (![301, 302, 303, 307, 308].includes(res.status) || !loc)
352
+ return { proceed: true, response: res, url: cur, hops, why: [] };
353
+ if (method !== "GET" && method !== "HEAD")
354
+ return { proceed: true, response: res, url: cur, hops, why: ["redirect not followed for " + method] };
355
+ try {
356
+ cur = new URL(loc, cur).toString();
357
+ }
358
+ catch {
359
+ return { proceed: false, response: null, url: cur, hops, why: ["unreadable redirect target " + loc] };
360
+ }
361
+ }
362
+ return { proceed: false, response: null, url: cur, hops, why: ["more than " + max + " redirects"] };
216
363
  }
217
364
  /** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
218
365
  /** Make a signed lockfile for an MCP server (CrawlCheck reads tools/list; no tool is called). Save it; check with checkLock before every connect. */
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ /** The server URL a transport will talk to, if it can be read (Streamable HTTP and SSE transports keep it as _url or url). */
2
+ export function transportUrl(transport: any): string | null;
3
+ /**
4
+ * The two hooks. cc is a CrawlCheck client (from this SDK). opts: template (default safe_mcp_tool), policy, agent,
5
+ * confirm(receipt) for require_confirmation, allowWarn, lock (an approved McpLock), confirmDestructive, maxAgeMs for a
6
+ * cached connect receipt (default: until the receipt's expires_at).
7
+ */
8
+ export function mcpPreflight(cc: any, opts?: {}): {
9
+ state: {
10
+ url: null;
11
+ host: null;
12
+ receipt: null;
13
+ verification: null;
14
+ decidedAt: number;
15
+ lockCheck: null;
16
+ approved: null;
17
+ };
18
+ /** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
19
+ beforeConnect(url: any): Promise<{
20
+ local: boolean;
21
+ receipt: null;
22
+ host?: undefined;
23
+ verification?: undefined;
24
+ } | {
25
+ local: boolean;
26
+ host: string;
27
+ receipt: any;
28
+ verification: any;
29
+ }>;
30
+ /** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
31
+ afterConnect(tools: any): Promise<import("./index.js").McpLockCheck | null>;
32
+ /** Before tools/call. */
33
+ beforeToolCall(name: any, annotations: any): Promise<{
34
+ receipt: null;
35
+ }>;
36
+ };
37
+ /**
38
+ * Wrap an MCP client in place: client.connect and client.callTool run the hooks first. Returns the same client, with
39
+ * client.preflight (the hooks and their state). Works on @modelcontextprotocol/sdk's Client and on anything with the same
40
+ * two methods; listTools() is used for the lockfile check when present.
41
+ */
42
+ export function withPreflight(client: any, cc: any, opts?: {}): any;
43
+ export class McpPreflightRefused extends Error {
44
+ constructor(stage: any, why: any, detail: any);
45
+ stage: any;
46
+ why: any;
47
+ receipt: any;
48
+ lock: any;
49
+ verification: any;
50
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,164 @@
1
+ // Preflight as an MCP client extension (https://crawlcheck.io/spec/mcp-preflight). Reference middleware: the check runs
2
+ // in the protocol path, before `initialize` reaches a server and before every `tools/call`, instead of being a call the
3
+ // agent remembers to make. It wraps any MCP client object that has connect(transport) and callTool(params) (the official
4
+ // @modelcontextprotocol/sdk Client does), or is used as two bare hooks for other client stacks.
5
+ //
6
+ // connect: the server's host is resolved and the "connect" action decided (template safe_mcp_tool). block, unsupported
7
+ // and an unverifiable receipt refuse the connect before a byte is sent; require_confirmation asks opts.confirm.
8
+ // With opts.lock, the tools the server lists after connecting must match the approved lockfile, or the client
9
+ // is closed again.
10
+ // tools/call: the connect receipt must still be unexpired (else the host is decided again); a tool outside the approved
11
+ // lockfile is refused; a tool annotated destructive (destructiveHint true, or readOnlyHint false with
12
+ // openWorldHint true) asks opts.confirm when opts.confirmDestructive is set.
13
+ // Nothing here calls a tool, reads arguments or alters the request; a refused call throws McpPreflightRefused and the
14
+ // request is never sent.
15
+ import { normalizeDomain } from "./index.js";
16
+ import { checkLock } from "./index.js";
17
+ export class McpPreflightRefused extends Error {
18
+ 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; }
19
+ }
20
+ /** The server URL a transport will talk to, if it can be read (Streamable HTTP and SSE transports keep it as _url or url). */
21
+ export function transportUrl(transport) {
22
+ if (!transport)
23
+ return null;
24
+ for (const k of ["_url", "url", "endpoint", "_endpoint"]) {
25
+ const v = transport[k];
26
+ if (v instanceof URL)
27
+ return v.toString();
28
+ if (typeof v === "string" && /^https?:\/\//i.test(v))
29
+ return v;
30
+ }
31
+ return null;
32
+ }
33
+ function hostOf(url) { try {
34
+ return normalizeDomain(new URL(url).hostname);
35
+ }
36
+ catch (e) {
37
+ return null;
38
+ } }
39
+ function destructive(ann) { if (!ann || typeof ann !== "object")
40
+ return false; if (ann.destructiveHint === true)
41
+ return true; return ann.readOnlyHint === false && ann.openWorldHint === true; }
42
+ /**
43
+ * The two hooks. cc is a CrawlCheck client (from this SDK). opts: template (default safe_mcp_tool), policy, agent,
44
+ * confirm(receipt) for require_confirmation, allowWarn, lock (an approved McpLock), confirmDestructive, maxAgeMs for a
45
+ * cached connect receipt (default: until the receipt's expires_at).
46
+ */
47
+ export function mcpPreflight(cc, opts = {}) {
48
+ const template = opts.template || "safe_mcp_tool";
49
+ const state = { url: null, host: null, receipt: null, verification: null, decidedAt: 0, lockCheck: null, approved: null };
50
+ const fresh = function () {
51
+ if (!state.receipt)
52
+ return false;
53
+ const exp = Date.parse(state.receipt.expires_at || "");
54
+ if (isFinite(exp) && Date.now() > exp)
55
+ return false;
56
+ if (opts.maxAgeMs && Date.now() - state.decidedAt > opts.maxAgeMs)
57
+ return false;
58
+ return true;
59
+ };
60
+ const decide = async function (host) {
61
+ const g = await cc.guard(host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys });
62
+ state.receipt = g.receipt;
63
+ state.verification = g.verification;
64
+ state.decidedAt = Date.now();
65
+ if (!g.proceed) {
66
+ const d = g.receipt && g.receipt.decision;
67
+ const decisionOnly = (g.why || []).every(function (w) { return /^decision is|a human did not confirm|no confirm\(\) was given|allowWarn is not set/.test(w); });
68
+ const why = g.split_view ? "the transparency log showed two histories; no answer is trusted" : !g.verification ? ((g.why || []).join("; ") || "no decision") : !g.verification.verified ? "the decision receipt did not verify" : g.verification.accepted === false ? "the receipt was signed with a key CrawlCheck does not publish" : !decisionOnly ? g.why.join("; ") : !g.verification.verified ? "the decision receipt did not verify" : g.verification.accepted === false ? "the receipt was signed with a key CrawlCheck does not publish" : d === "require_confirmation" ? "a human must confirm and none did" : d === "warn" ? "warn without allowWarn" : d === "unsupported" ? "nothing vouches for a connection to " + host + " (" + ((g.receipt.steps || [])[0] || {}).why + ")" : "decision " + d + (g.receipt.steps && g.receipt.steps[0] ? " (" + g.receipt.steps[0].why + ")" : "");
69
+ throw new McpPreflightRefused("connect", why, { receipt: g.receipt, verification: g.verification });
70
+ }
71
+ return g;
72
+ };
73
+ return {
74
+ state,
75
+ /** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
76
+ async beforeConnect(url) {
77
+ if (!url) {
78
+ if (opts.local === "refuse")
79
+ throw new McpPreflightRefused("connect", "a local (stdio) server cannot be resolved and opts.local is refuse");
80
+ state.url = null;
81
+ state.host = null;
82
+ return { local: true, receipt: null };
83
+ }
84
+ const host = hostOf(url);
85
+ if (!host)
86
+ throw new McpPreflightRefused("connect", "not a server URL: " + url);
87
+ state.url = url;
88
+ state.host = host;
89
+ const g = await decide(host);
90
+ return { local: false, host, receipt: g.receipt, verification: g.verification };
91
+ },
92
+ /** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
93
+ async afterConnect(tools) {
94
+ if (!opts.lock) {
95
+ state.approved = null;
96
+ return null;
97
+ }
98
+ const lc = await checkLock(opts.lock, tools || []);
99
+ state.lockCheck = lc;
100
+ state.approved = new Set((opts.lock.tools || []).map(function (t) { return t.name; }));
101
+ if (lc.decision !== "connect")
102
+ throw new McpPreflightRefused("connect", "the server's tools differ from the approved lockfile (added " + lc.added.length + ", removed " + lc.removed.length + ", modified " + lc.modified.length + ")", { lock: lc });
103
+ return lc;
104
+ },
105
+ /** Before tools/call. */
106
+ async beforeToolCall(name, annotations) {
107
+ if (state.host && !fresh())
108
+ await decide(state.host);
109
+ if (state.approved && !state.approved.has(name))
110
+ throw new McpPreflightRefused("tools/call", "tool " + name + " is not in the approved lockfile", { lock: state.lockCheck, receipt: state.receipt });
111
+ if (opts.confirmDestructive && destructive(annotations)) {
112
+ const ok = opts.confirm ? await opts.confirm(Object.assign({}, state.receipt || {}, { tool: name, annotations })) : false;
113
+ if (!ok)
114
+ throw new McpPreflightRefused("tools/call", "tool " + name + " is annotated destructive and no human confirmed", { receipt: state.receipt });
115
+ }
116
+ return { receipt: state.receipt };
117
+ }
118
+ };
119
+ }
120
+ /**
121
+ * Wrap an MCP client in place: client.connect and client.callTool run the hooks first. Returns the same client, with
122
+ * client.preflight (the hooks and their state). Works on @modelcontextprotocol/sdk's Client and on anything with the same
123
+ * two methods; listTools() is used for the lockfile check when present.
124
+ */
125
+ export function withPreflight(client, cc, opts = {}) {
126
+ const pf = mcpPreflight(cc, opts);
127
+ const connect = client.connect.bind(client), callTool = client.callTool.bind(client);
128
+ const tools = {};
129
+ client.connect = async function (transport, ...rest) {
130
+ await pf.beforeConnect(opts.serverUrl || transportUrl(transport));
131
+ const r = await connect(transport, ...rest);
132
+ if (opts.lock || opts.confirmDestructive) {
133
+ let list = [];
134
+ try {
135
+ const t = typeof client.listTools === "function" ? await client.listTools() : null;
136
+ list = (t && t.tools) || [];
137
+ }
138
+ catch (e) {
139
+ list = [];
140
+ }
141
+ list.forEach(function (t) { if (t && t.name)
142
+ tools[t.name] = t; });
143
+ try {
144
+ await pf.afterConnect(list);
145
+ }
146
+ catch (e) {
147
+ try {
148
+ if (typeof client.close === "function")
149
+ await client.close();
150
+ }
151
+ catch (e2) { }
152
+ throw e;
153
+ }
154
+ }
155
+ return r;
156
+ };
157
+ client.callTool = async function (params, ...rest) {
158
+ const name = params && params.name;
159
+ await pf.beforeToolCall(name, tools[name] && tools[name].annotations);
160
+ return callTool(params, ...rest);
161
+ };
162
+ client.preflight = pf;
163
+ return client;
164
+ }