@crawlcheck/sdk 1.0.8 → 1.0.9

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
@@ -356,6 +356,71 @@ export declare const stapleCheck: (record: string, opts: {
356
356
  publishedKids: string[];
357
357
  now?: number;
358
358
  }) => Promise<StapleCheck>;
359
+ export interface McpPreflightOptions extends PreflightOptions {
360
+ /** Proceed on warn too (default false). */
361
+ allowWarn?: boolean;
362
+ /** Called on require_confirmation (and, with confirmDestructive, before a destructive tool call); the connect or call proceeds only if it resolves true. */
363
+ confirm?: (receipt: DecisionReceipt & {
364
+ tool?: string;
365
+ annotations?: unknown;
366
+ }) => boolean | Promise<boolean>;
367
+ /** An approved lockfile (mcpLock / lockFromTools): the tools the server lists after connecting must match it, and only tools in it may be called. */
368
+ lock?: McpLock;
369
+ /** Ask confirm() before a tool annotated destructive (destructiveHint true, or readOnlyHint false with openWorldHint true). */
370
+ confirmDestructive?: boolean;
371
+ /** Re-decide the host when the cached connect receipt is older than this (default: until its expires_at). */
372
+ maxAgeMs?: number;
373
+ /** A local (stdio) server has no host to resolve: "allow" (default) or "refuse". */
374
+ local?: "allow" | "refuse";
375
+ /** The server URL when the transport does not expose it. */
376
+ serverUrl?: string;
377
+ onlineKeys?: boolean;
378
+ }
379
+ export interface McpPreflightState {
380
+ url: string | null;
381
+ host: string | null;
382
+ receipt: DecisionReceipt | null;
383
+ verification: (DocumentVerification & Trust) | null;
384
+ decidedAt: number;
385
+ lockCheck: McpLockCheck | null;
386
+ approved: Set<string> | null;
387
+ }
388
+ export interface McpPreflightHooks {
389
+ state: McpPreflightState;
390
+ beforeConnect(url: string | null | undefined): Promise<{
391
+ local: boolean;
392
+ host?: string;
393
+ receipt: DecisionReceipt | null;
394
+ verification?: DocumentVerification & Trust;
395
+ }>;
396
+ afterConnect(tools: McpTool[]): Promise<McpLockCheck | null>;
397
+ beforeToolCall(name: string, annotations?: unknown): Promise<{
398
+ receipt: DecisionReceipt | null;
399
+ }>;
400
+ }
401
+ /** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
402
+ export declare const McpPreflightRefused: new (stage: "connect" | "tools/call", why: string, detail?: {
403
+ receipt?: DecisionReceipt | null;
404
+ lock?: McpLockCheck | null;
405
+ verification?: unknown;
406
+ }) => Error & {
407
+ stage: "connect" | "tools/call";
408
+ why: string;
409
+ receipt: DecisionReceipt | null;
410
+ lock: McpLockCheck | null;
411
+ verification: unknown;
412
+ };
413
+ /** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
414
+ export declare const mcpPreflight: (cc: CrawlCheck, opts?: McpPreflightOptions) => McpPreflightHooks;
415
+ /** 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. */
416
+ export declare const withPreflight: <C extends {
417
+ connect: (...a: any[]) => Promise<any>;
418
+ callTool: (...a: any[]) => Promise<any>;
419
+ }>(client: C, cc: CrawlCheck, opts?: McpPreflightOptions) => C & {
420
+ preflight: McpPreflightHooks;
421
+ };
422
+ /** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
423
+ export declare const transportUrl: (transport: unknown) => string | null;
359
424
  export interface ClientOptions {
360
425
  /** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
361
426
  key?: string;
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;
@@ -83,6 +84,14 @@ export const parseStaple = S.parseStaple;
83
84
  export const stapleFromResponse = S.stapleFromResponse;
84
85
  /** 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
86
  export const stapleCheck = S.stapleCheck;
87
+ /** Thrown by the hooks when a connect or tools/call is refused; the request was never sent. */
88
+ export const McpPreflightRefused = M.McpPreflightRefused;
89
+ /** The two hooks (beforeConnect, beforeToolCall) plus afterConnect for lockfiles, for any MCP client stack. */
90
+ export const mcpPreflight = M.mcpPreflight;
91
+ /** 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. */
92
+ export const withPreflight = M.withPreflight;
93
+ /** The server URL a transport will talk to, if it exposes one (Streamable HTTP and SSE transports do). */
94
+ export const transportUrl = M.transportUrl;
86
95
  export class CrawlCheckError extends Error {
87
96
  status;
88
97
  body;
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,163 @@
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 why = g.split_view ? "the transparency log showed two histories; no answer is trusted" : !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 + ")" : "");
68
+ throw new McpPreflightRefused("connect", why, { receipt: g.receipt, verification: g.verification });
69
+ }
70
+ return g;
71
+ };
72
+ return {
73
+ state,
74
+ /** Before initialize. url: the server URL (a stdio/local server has none and is not decided; pass opts.local = "allow" | "refuse", default allow). */
75
+ async beforeConnect(url) {
76
+ if (!url) {
77
+ if (opts.local === "refuse")
78
+ throw new McpPreflightRefused("connect", "a local (stdio) server cannot be resolved and opts.local is refuse");
79
+ state.url = null;
80
+ state.host = null;
81
+ return { local: true, receipt: null };
82
+ }
83
+ const host = hostOf(url);
84
+ if (!host)
85
+ throw new McpPreflightRefused("connect", "not a server URL: " + url);
86
+ state.url = url;
87
+ state.host = host;
88
+ const g = await decide(host);
89
+ return { local: false, host, receipt: g.receipt, verification: g.verification };
90
+ },
91
+ /** After initialize, with the tools the server lists. Refuses (and the wrapper closes the client) when they differ from the approved lockfile. */
92
+ async afterConnect(tools) {
93
+ if (!opts.lock) {
94
+ state.approved = null;
95
+ return null;
96
+ }
97
+ const lc = await checkLock(opts.lock, tools || []);
98
+ state.lockCheck = lc;
99
+ state.approved = new Set((opts.lock.tools || []).map(function (t) { return t.name; }));
100
+ if (lc.decision !== "connect")
101
+ 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 });
102
+ return lc;
103
+ },
104
+ /** Before tools/call. */
105
+ async beforeToolCall(name, annotations) {
106
+ if (state.host && !fresh())
107
+ await decide(state.host);
108
+ if (state.approved && !state.approved.has(name))
109
+ throw new McpPreflightRefused("tools/call", "tool " + name + " is not in the approved lockfile", { lock: state.lockCheck, receipt: state.receipt });
110
+ if (opts.confirmDestructive && destructive(annotations)) {
111
+ const ok = opts.confirm ? await opts.confirm(Object.assign({}, state.receipt || {}, { tool: name, annotations })) : false;
112
+ if (!ok)
113
+ throw new McpPreflightRefused("tools/call", "tool " + name + " is annotated destructive and no human confirmed", { receipt: state.receipt });
114
+ }
115
+ return { receipt: state.receipt };
116
+ }
117
+ };
118
+ }
119
+ /**
120
+ * Wrap an MCP client in place: client.connect and client.callTool run the hooks first. Returns the same client, with
121
+ * client.preflight (the hooks and their state). Works on @modelcontextprotocol/sdk's Client and on anything with the same
122
+ * two methods; listTools() is used for the lockfile check when present.
123
+ */
124
+ export function withPreflight(client, cc, opts = {}) {
125
+ const pf = mcpPreflight(cc, opts);
126
+ const connect = client.connect.bind(client), callTool = client.callTool.bind(client);
127
+ const tools = {};
128
+ client.connect = async function (transport, ...rest) {
129
+ await pf.beforeConnect(opts.serverUrl || transportUrl(transport));
130
+ const r = await connect(transport, ...rest);
131
+ if (opts.lock || opts.confirmDestructive) {
132
+ let list = [];
133
+ try {
134
+ const t = typeof client.listTools === "function" ? await client.listTools() : null;
135
+ list = (t && t.tools) || [];
136
+ }
137
+ catch (e) {
138
+ list = [];
139
+ }
140
+ list.forEach(function (t) { if (t && t.name)
141
+ tools[t.name] = t; });
142
+ try {
143
+ await pf.afterConnect(list);
144
+ }
145
+ catch (e) {
146
+ try {
147
+ if (typeof client.close === "function")
148
+ await client.close();
149
+ }
150
+ catch (e2) { }
151
+ throw e;
152
+ }
153
+ }
154
+ return r;
155
+ };
156
+ client.callTool = async function (params, ...rest) {
157
+ const name = params && params.name;
158
+ await pf.beforeToolCall(name, tools[name] && tools[name].annotations);
159
+ return callTool(params, ...rest);
160
+ };
161
+ client.preflight = pf;
162
+ return client;
163
+ }
@@ -0,0 +1,33 @@
1
+ // Preflight as an MCP client extension: the official MCP TypeScript client, wrapped so `connect` and `callTool` are
2
+ // decided before anything is sent. Run: node mcp-preflight-demo.mjs (needs @modelcontextprotocol/sdk and @crawlcheck/sdk).
3
+ //
4
+ // 1. A server on a domain nothing vouches for -> the connect is refused; no packet leaves the client.
5
+ // 2. CrawlCheck's own MCP server (connect = require_confirmation) -> refused when nobody confirms, connected when a
6
+ // person says yes; the signed receipt is shown and verifies offline.
7
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
+ import { CrawlCheck, withPreflight, McpPreflightRefused } from "@crawlcheck/sdk";
10
+
11
+ const cc = new CrawlCheck();
12
+ const sent = [];
13
+ const watchedFetch = async (u, init) => { sent.push((init && init.method || "GET") + " " + String(u)); return fetch(u, init); }; // every request the transport makes
14
+
15
+ async function attempt(label, url, opts) {
16
+ const client = withPreflight(new Client({ name: "preflight-demo", version: "1.0.0" }), cc, opts);
17
+ const before = sent.length;
18
+ try {
19
+ await client.connect(new StreamableHTTPClientTransport(new URL(url), { fetch: watchedFetch }));
20
+ const tools = await client.listTools();
21
+ console.log(label + ": CONNECTED, " + tools.tools.length + " tools listed; receipt " + client.preflight.state.receipt.decision + " sha256 " + client.preflight.state.receipt.sha256.slice(0, 16));
22
+ await client.close();
23
+ } catch (e) {
24
+ if (!(e instanceof McpPreflightRefused)) throw e;
25
+ console.log(label + ": REFUSED at " + e.stage + " - " + e.why);
26
+ console.log(" decision " + (e.receipt && e.receipt.decision) + ", receipt sha256 " + (e.receipt && e.receipt.sha256 && e.receipt.sha256.slice(0, 16)) + ", verifies offline: " + (e.verification && e.verification.verified));
27
+ }
28
+ console.log(" requests the MCP transport sent: " + (sent.length - before));
29
+ }
30
+
31
+ await attempt("1. mcp.example.com (no record)", "https://mcp.example.com/mcp", {});
32
+ await attempt("2a. crawlcheck.io/mcp, nobody to confirm", "https://crawlcheck.io/mcp", {});
33
+ await attempt("2b. crawlcheck.io/mcp, a person confirms", "https://crawlcheck.io/mcp", { confirm: async (r) => { console.log(" confirm? " + r.decision + ": " + ((r.steps || [])[0] || {}).why); return true; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Typed client for the CrawlCheck API (generated from its OpenAPI document) and the zero-dependency offline verifier for evidence bundles and remediation receipts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,6 +14,7 @@
14
14
  "files": [
15
15
  "dist",
16
16
  "test",
17
+ "examples",
17
18
  "README.md"
18
19
  ],
19
20
  "engines": {
@@ -0,0 +1,81 @@
1
+ import test from "node:test"; import assert from "node:assert/strict";
2
+ import { withPreflight, mcpPreflight, McpPreflightRefused, transportUrl, lockFromTools } from "../dist/index.js";
3
+
4
+ // A CrawlCheck client double: guard() answers from a table, no network.
5
+ function cc(table) {
6
+ const calls = [];
7
+ return { calls, async guard(host, action, opts) {
8
+ calls.push({ host, action, template: opts.template });
9
+ const d = table[host] || "unsupported";
10
+ const receipt = { kind: "crawlcheck-decision", domain: host, action, decision: d, expires_at: new Date(Date.now() + 3600e3).toISOString(), steps: [{ why: "table" }], sha256: "x", signature: { kid: "k" } };
11
+ let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
12
+ if (d === "require_confirmation" && opts.confirm) proceed = !!(await opts.confirm(receipt));
13
+ return { proceed, receipt, verification: { verified: true, accepted: true, integrity_valid: true, issuer_trusted: true } };
14
+ } };
15
+ }
16
+ // An MCP client double with the official Client's surface.
17
+ function mcpClient(tools) {
18
+ const sent = [];
19
+ return { sent, closed: false,
20
+ async connect(transport) { sent.push("initialize " + transportUrl(transport)); },
21
+ async listTools() { return { tools }; },
22
+ async callTool(p) { sent.push("tools/call " + p.name); return { content: [] }; },
23
+ async close() { this.closed = true; } };
24
+ }
25
+ const T = (url) => ({ _url: new URL(url) });
26
+ const TOOLS = [{ name: "search", description: "find", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, { name: "delete_repo", description: "rm", inputSchema: { type: "object" }, annotations: { destructiveHint: true } }];
27
+
28
+ test("connect to a host nothing vouches for is refused before initialize", async () => {
29
+ const c = withPreflight(mcpClient(TOOLS), cc({}));
30
+ await assert.rejects(() => c.connect(T("https://mcp.example.com/mcp")), (e) => e instanceof McpPreflightRefused && e.stage === "connect" && /nothing vouches/.test(e.why) && e.receipt.decision === "unsupported");
31
+ assert.deepEqual(c.sent, []); // no packet left the client
32
+ });
33
+ test("block refuses the connect", async () => {
34
+ const c = withPreflight(mcpClient(TOOLS), cc({ "evil.example": "block" }));
35
+ await assert.rejects(() => c.connect(T("https://evil.example/mcp")), (e) => e.stage === "connect" && e.receipt.decision === "block");
36
+ assert.deepEqual(c.sent, []);
37
+ });
38
+ test("allow connects; the host is decided once with the safe_mcp_tool template", async () => {
39
+ const k = cc({ "good.example": "allow" }); const c = withPreflight(mcpClient(TOOLS), k);
40
+ await c.connect(T("https://good.example/mcp"));
41
+ await c.callTool({ name: "search", arguments: {} });
42
+ assert.deepEqual(c.sent, ["initialize https://good.example/mcp", "tools/call search"]);
43
+ assert.equal(k.calls.length, 1); assert.equal(k.calls[0].template, "safe_mcp_tool"); assert.equal(k.calls[0].action, "connect");
44
+ });
45
+ test("require_confirmation: refused without a confirm, connects when a person says yes", async () => {
46
+ const k = cc({ "ask.example": "require_confirmation" });
47
+ await assert.rejects(() => withPreflight(mcpClient(TOOLS), k).connect(T("https://ask.example/mcp")), (e) => /human must confirm/.test(e.why));
48
+ let seen = null; const c = withPreflight(mcpClient(TOOLS), k, { confirm: async (r) => { seen = r; return true; } });
49
+ await c.connect(T("https://ask.example/mcp")); assert.equal(seen.decision, "require_confirmation"); assert.equal(c.sent.length, 1);
50
+ });
51
+ test("lockfile: changed tools close the client again; a tool outside the lock is refused", async () => {
52
+ const lock = await lockFromTools([TOOLS[0]]);
53
+ const c = withPreflight(mcpClient(TOOLS), cc({ "good.example": "allow" }), { lock });
54
+ await assert.rejects(() => c.connect(T("https://good.example/mcp")), (e) => e.stage === "connect" && /differ from the approved lockfile/.test(e.why) && e.lock.added.includes("delete_repo"));
55
+ assert.equal(c.closed, true);
56
+ const c2 = withPreflight(mcpClient([TOOLS[0]]), cc({ "good.example": "allow" }), { lock });
57
+ await c2.connect(T("https://good.example/mcp")); await c2.callTool({ name: "search" });
58
+ await assert.rejects(() => c2.callTool({ name: "delete_repo" }), (e) => e.stage === "tools/call" && /not in the approved lockfile/.test(e.why));
59
+ assert.deepEqual(c2.sent, ["initialize https://good.example/mcp", "tools/call search"]);
60
+ });
61
+ test("destructive tool asks for confirmation", async () => {
62
+ const c = withPreflight(mcpClient(TOOLS), cc({ "good.example": "allow" }), { confirmDestructive: true, confirm: async (r) => r.tool !== "delete_repo" });
63
+ await c.connect(T("https://good.example/mcp"));
64
+ await c.callTool({ name: "search" });
65
+ await assert.rejects(() => c.callTool({ name: "delete_repo" }), (e) => /annotated destructive/.test(e.why));
66
+ assert.deepEqual(c.sent, ["initialize https://good.example/mcp", "tools/call search"]);
67
+ });
68
+ test("expired connect receipt is decided again before the next call", async () => {
69
+ const k = cc({ "good.example": "allow" }); const c = withPreflight(mcpClient(TOOLS), k, { maxAgeMs: 1 });
70
+ await c.connect(T("https://good.example/mcp")); await new Promise((r) => setTimeout(r, 5));
71
+ await c.callTool({ name: "search" }); assert.equal(k.calls.length, 2);
72
+ });
73
+ test("local stdio server: allowed by default, refusable", async () => {
74
+ const c = withPreflight(mcpClient(TOOLS), cc({})); await c.connect({ kind: "stdio" }); assert.equal(c.preflight.state.host, null);
75
+ await assert.rejects(() => withPreflight(mcpClient(TOOLS), cc({}), { local: "refuse" }).connect({ kind: "stdio" }), (e) => /local/.test(e.why));
76
+ });
77
+ test("bare hooks work without a client object", async () => {
78
+ const h = mcpPreflight(cc({ "good.example": "allow" }));
79
+ const r = await h.beforeConnect("https://good.example/mcp"); assert.equal(r.host, "good.example");
80
+ assert.equal((await h.beforeToolCall("search")).receipt.decision, "allow");
81
+ });