@crawlcheck/sdk 1.11.0 → 1.12.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
@@ -160,6 +160,25 @@ const client = withPreflight(new Client(...), new CrawlCheck(), {
160
160
 
161
161
  The default policy (`CONTINUITY_DEFAULT_POLICY`) pauses added, changed-description, changed-schema and changed-annotation tools, refreshes expired evidence, pauses on an unreachable server or service, a truncated or unreadable tool list, a stale baseline or an unexpected policy version, and denies on an invalid signature, an unpublished or retired key, or a different server. Override any effect; the integrity classes stay `deny` unless you set `allow_integrity_override`, and the record says so. Exceptions (`{ tool, class, until, by, reason }`) proceed as `proceed_recorded`, never as an ordinary pass. Every record (`crawlcheck-continuity-record`) names the baseline, what was observed, each event, the policy's sha256, the mode, the outcome and its own `record_sha256`. It states interface continuity at the check point: not code attestation, not a judgement that a tool is harmless, not user authorisation. `continuityCheck(lock, tools, opts)` and `continuityFailure(cls, opts)` run the same logic without a client; the Python SDK reproduces every record byte for byte (shared golden file `test/fixtures/continuity.json`). 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`.
162
162
 
163
+ ### Continuity in your agent framework (1.12.0)
164
+
165
+ The same check where the framework fetches an MCP server's tool list, against your approved lockfile. No CrawlCheck call, no key, no new dependency: each adapter uses the framework you already have.
166
+
167
+ ```js
168
+ import { withContinuity, loadMcpToolsWithContinuity } from "@crawlcheck/sdk";
169
+
170
+ // OpenAI Agents SDK (@openai/agents)
171
+ const server = new MCPServerStreamableHttp({ url });
172
+ await server.connect();
173
+ const agent = new Agent({ name: "a", instructions: "...", mcpServers: [withContinuity(server, lock, { mode: "observe" })] });
174
+
175
+ // LangChain (@langchain/mcp-adapters)
176
+ const client = new MultiServerMCPClient({ mcpServers: { shop: { url } } });
177
+ const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" });
178
+ ```
179
+
180
+ `observe` records every difference (`console.warn`, `onRecord`, `server.continuity.records` / `tools.continuity.records`) and passes the tools through. `review` pauses the whole server on any change until `confirm` says yes. `enforce` applies the policy: a changed or new tool is withheld from the agent, and a call to it is refused, until `confirm({ tool, continuity })` re-approves it; the rest stay usable. A refusal throws `ContinuityRefused` (with `.record`). A yes is remembered for that exact definition, so a framework that lists tools every turn does not ask twice. For CrewAI, use the Python package (`crawlcheck.crewai.with_continuity`).
181
+
163
182
  ## Do not trust the API: check the evidence yourself
164
183
 
165
184
  ```js
@@ -0,0 +1,39 @@
1
+ export declare class ContinuityRefused extends Error {
2
+ why: any;
3
+ record: any;
4
+ tool: any;
5
+ constructor(why: any, record?: null, tool?: null);
6
+ }
7
+ /** The shared gate. lock: an approved mcp-lockfile (with lock.parts so a change can be named). */
8
+ export declare function continuityGate(lock: any, opts?: {}): {
9
+ mode: any;
10
+ records: never[];
11
+ record: null;
12
+ paused: Set<any>;
13
+ keepRecord(rec: any): Promise<any>;
14
+ ask(req: any): Promise<boolean>;
15
+ /** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
16
+ check(tools: any, meta?: {}): Promise<any>;
17
+ /** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
18
+ fail(cls: any, detail: any): Promise<any>;
19
+ /** The tools the agent may see: all of them in observe mode, else without the paused ones. */
20
+ allowed(tools: any, nameOf?: (t: any) => any): any;
21
+ /** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
22
+ callOk(name: any): boolean;
23
+ };
24
+ /**
25
+ * OpenAI Agents SDK (@openai/agents): wrap an MCP server (MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE or your
26
+ * own MCPServer) so every listTools() the SDK makes is checked against lock. Everything else is the server's own.
27
+ * const guarded = withContinuity(server, lock, { mode: "observe" }); new Agent({ name, instructions, mcpServers: [guarded] })
28
+ * The records are at guarded.continuity.records.
29
+ */
30
+ export declare function withContinuity(server: any, lock: any, opts?: {}): any;
31
+ /**
32
+ * LangChain (@langchain/mcp-adapters): loadMcpTools with the continuity check.
33
+ * const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" })
34
+ * client: a MultiServerMCPClient (its connection for serverName is used) or a connected MCP SDK Client. The list is read
35
+ * once (every page) and the LangChain tools are converted from exactly the definitions that were compared.
36
+ * opts.adapters: the @langchain/mcp-adapters module (default: imported on first use); opts.loadOptions: its
37
+ * LoadMcpToolsOptions. The gate (records) is opts.gate, or read them from opts.onRecord.
38
+ */
39
+ export declare function loadMcpToolsWithContinuity(serverName: any, client: any, lock: any, opts?: {}): Promise<any>;
@@ -0,0 +1,224 @@
1
+ // Tool-interface continuity for agent frameworks (1.12.0, https://crawlcheck.io/spec/continuity). The same contract as the
2
+ // Python package's continuity_gate.py and its openai_agents / langchain / crewai adapters.
3
+ //
4
+ // The check runs where the framework fetches an MCP server's tool list, against the lockfile the operator approved:
5
+ // observe every difference is recorded (gate.records, onRecord, console.warn unless log: false) and the tools pass
6
+ // through unchanged. Nothing is blocked.
7
+ // review any difference pauses the whole server until confirm() says yes; integrity failures are denied.
8
+ // enforce the policy applies: by default a changed or new tool is withheld from the agent (pause_tool) until confirm()
9
+ // re-approves it; a whole-server pause or refresh asks confirm() once; deny throws ContinuityRefused.
10
+ // confirm(request) may return a promise; request is { continuity: record } for the server or { tool, continuity } for one
11
+ // tool. A yes is remembered for that exact definition, so a framework that lists tools every turn does not ask twice.
12
+ // No framework is a dependency of this package: each adapter imports its framework only when called.
13
+ import { continuityCheck, continuityFailure } from "./continuity.js";
14
+ import { canon } from "./verify.js";
15
+ export class ContinuityRefused extends Error {
16
+ constructor(why, record = null, tool = null) {
17
+ super("CrawlCheck continuity refused" + (tool ? " tool " + tool : "") + ": " + why);
18
+ this.name = "ContinuityRefused";
19
+ this.why = why;
20
+ this.record = record;
21
+ this.tool = tool;
22
+ }
23
+ }
24
+ const pick = (t) => ({ name: t?.name, description: t?.description, inputSchema: t?.inputSchema ?? t?.input_schema, annotations: t?.annotations });
25
+ /** The shared gate. lock: an approved mcp-lockfile (with lock.parts so a change can be named). */
26
+ export function continuityGate(lock, opts = {}) {
27
+ const mode = opts.mode || "observe";
28
+ if (["observe", "review", "enforce"].indexOf(mode) < 0)
29
+ throw new Error("mode must be observe, review or enforce");
30
+ if (!lock || !Array.isArray(lock.tools))
31
+ throw new Error("lock must be an approved mcp-lockfile (an object with a tools array); see https://crawlcheck.io/spec/mcp-lockfile");
32
+ const keep = opts.keep ?? 50, okTools = new Set(), okLists = new Set();
33
+ let lastKey = null;
34
+ const co = { mode, policy: opts.policy, exceptions: opts.exceptions, server: opts.server ?? undefined, serverIdentity: opts.serverIdentity, expectPolicySha256: opts.expectPolicySha256 };
35
+ const gate = {
36
+ mode, records: [], record: null, paused: new Set(),
37
+ async keepRecord(rec) {
38
+ gate.record = rec;
39
+ gate.records = gate.records.concat([rec]).slice(-keep);
40
+ const changed = rec.events.some((e) => e.class !== "unchanged");
41
+ if (changed && opts.log !== false)
42
+ (opts.logger || console).warn("crawlcheck continuity " + rec.mode + " " + (rec.server || "-") + ": would " + rec.would + ", outcome " + rec.outcome + ": " + rec.why.join("; "));
43
+ if (opts.onRecord) {
44
+ try {
45
+ await opts.onRecord(rec);
46
+ }
47
+ catch (e) { /* recording never breaks the agent */ }
48
+ }
49
+ return rec;
50
+ },
51
+ async ask(req) {
52
+ if (!opts.confirm)
53
+ return false;
54
+ try {
55
+ return !!(await opts.confirm(req));
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ },
61
+ /** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
62
+ async check(tools, meta = {}) {
63
+ const complete = meta.complete !== false;
64
+ const rows = Array.isArray(tools) ? tools.map(pick) : tools;
65
+ let rec = await continuityCheck(lock, rows, Object.assign({}, co, { complete }));
66
+ const key = (rec.observed?.tools_sha256 ?? "") + "|" + complete;
67
+ if (lastKey === key && gate.record)
68
+ rec = gate.record;
69
+ else {
70
+ lastKey = key;
71
+ await gate.keepRecord(rec);
72
+ }
73
+ if (mode === "observe") {
74
+ gate.paused = new Set();
75
+ return rec;
76
+ }
77
+ if (rec.outcome === "deny")
78
+ throw new ContinuityRefused(rec.why.join("; "), rec);
79
+ if (rec.outcome === "pause" || rec.outcome === "refresh") {
80
+ if (!okLists.has(key)) {
81
+ if (!(await gate.ask({ continuity: rec })))
82
+ throw new ContinuityRefused(rec.why.join("; "), rec);
83
+ okLists.add(key);
84
+ }
85
+ gate.paused = new Set();
86
+ return rec;
87
+ }
88
+ const byName = {};
89
+ for (const r of Array.isArray(rows) ? rows : [])
90
+ byName[String(r.name ?? "").slice(0, 120)] = canon(r);
91
+ const paused = new Set();
92
+ for (const n of rec.paused_tools) {
93
+ const k = n + "\n" + (byName[n] ?? "");
94
+ if (okTools.has(k) || (await gate.ask({ tool: n, continuity: rec })))
95
+ okTools.add(k);
96
+ else
97
+ paused.add(n);
98
+ }
99
+ gate.paused = paused;
100
+ return rec;
101
+ },
102
+ /** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
103
+ async fail(cls, detail) {
104
+ const rec = await continuityFailure(cls, Object.assign({}, co, { detail: detail ? String(detail).slice(0, 300) : undefined }));
105
+ lastKey = null;
106
+ await gate.keepRecord(rec);
107
+ if (mode !== "observe" && rec.outcome !== "proceed")
108
+ throw new ContinuityRefused(rec.why.join("; "), rec);
109
+ return rec;
110
+ },
111
+ /** The tools the agent may see: all of them in observe mode, else without the paused ones. */
112
+ allowed(tools, nameOf = (t) => t?.name) { return mode === "observe" || !gate.paused.size ? tools.slice() : tools.filter((t) => !gate.paused.has(nameOf(t))); },
113
+ /** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
114
+ callOk(name) { if (mode !== "observe" && gate.paused.has(name))
115
+ throw new ContinuityRefused("tool " + name + " changed since it was approved (or is new) and no person re-approved it", gate.record, name); return true; },
116
+ };
117
+ return gate;
118
+ }
119
+ const urlOf = (s) => {
120
+ for (const p of [s && s.params, s && s.underlying && s.underlying.params, s && s.options])
121
+ if (p && typeof p.url === "string" && p.url)
122
+ return p.url;
123
+ return null;
124
+ };
125
+ /**
126
+ * OpenAI Agents SDK (@openai/agents): wrap an MCP server (MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE or your
127
+ * own MCPServer) so every listTools() the SDK makes is checked against lock. Everything else is the server's own.
128
+ * const guarded = withContinuity(server, lock, { mode: "observe" }); new Agent({ name, instructions, mcpServers: [guarded] })
129
+ * The records are at guarded.continuity.records.
130
+ */
131
+ export function withContinuity(server, lock, opts = {}) {
132
+ const gate = opts.gate || continuityGate(lock, Object.assign({ server: opts.serverUrl ?? urlOf(server) ?? undefined }, opts));
133
+ const listTools = async (...a) => {
134
+ let tools;
135
+ try {
136
+ tools = await server.listTools(...a);
137
+ }
138
+ catch (e) {
139
+ await gate.fail("server_unavailable", e && e.message);
140
+ throw e;
141
+ }
142
+ await gate.check(tools, { complete: true }); // the Agents SDK reads every page or throws
143
+ return gate.allowed(tools);
144
+ };
145
+ const callTool = async (name, ...a) => { gate.callOk(name); return server.callTool(name, ...a); };
146
+ const callToolResult = server.callToolResult ? async (name, ...a) => { gate.callOk(name); return server.callToolResult(name, ...a); } : undefined;
147
+ return new Proxy(server, {
148
+ get(target, k) {
149
+ if (k === "listTools")
150
+ return listTools;
151
+ if (k === "callTool")
152
+ return callTool;
153
+ if (k === "callToolResult" && callToolResult)
154
+ return callToolResult;
155
+ if (k === "continuity")
156
+ return gate;
157
+ const v = Reflect.get(target, k, target);
158
+ return typeof v === "function" ? v.bind(target) : v; // bound: the servers keep private (#) state
159
+ },
160
+ });
161
+ }
162
+ async function listAll(client) {
163
+ const out = [];
164
+ let cursor, pages = 0;
165
+ for (;;) {
166
+ const r = await client.listTools(cursor ? { cursor } : undefined);
167
+ out.push(...(r?.tools ?? []));
168
+ const next = r?.nextCursor;
169
+ if (!next)
170
+ return { tools: out, complete: true };
171
+ if (++pages >= 1000 || next === cursor)
172
+ return { tools: out, complete: false };
173
+ cursor = next;
174
+ }
175
+ }
176
+ /**
177
+ * LangChain (@langchain/mcp-adapters): loadMcpTools with the continuity check.
178
+ * const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" })
179
+ * client: a MultiServerMCPClient (its connection for serverName is used) or a connected MCP SDK Client. The list is read
180
+ * once (every page) and the LangChain tools are converted from exactly the definitions that were compared.
181
+ * opts.adapters: the @langchain/mcp-adapters module (default: imported on first use); opts.loadOptions: its
182
+ * LoadMcpToolsOptions. The gate (records) is opts.gate, or read them from opts.onRecord.
183
+ */
184
+ export async function loadMcpToolsWithContinuity(serverName, client, lock, opts = {}) {
185
+ const lc = opts.adapters || (await import("@langchain/mcp-adapters"));
186
+ let mcp = client;
187
+ if (client && typeof client.getClient === "function" && typeof client.callTool !== "function") {
188
+ mcp = await client.getClient(serverName);
189
+ if (!mcp)
190
+ throw new Error("no connected MCP server named " + serverName);
191
+ }
192
+ let conn = null;
193
+ try {
194
+ const c = client && client !== mcp ? client.config : null;
195
+ conn = c ? ((c.servers || c.mcpServers || {})[serverName] ?? null) : null;
196
+ }
197
+ catch {
198
+ conn = null;
199
+ }
200
+ const gate = opts.gate || continuityGate(lock, Object.assign({ server: opts.serverUrl ?? (conn && conn.url) ?? undefined }, opts));
201
+ let listed;
202
+ try {
203
+ listed = await listAll(mcp);
204
+ }
205
+ catch (e) {
206
+ await gate.fail("server_unavailable", e && e.message);
207
+ throw e;
208
+ }
209
+ await gate.check(structuredClone(listed.tools), { complete: listed.complete });
210
+ const allowed = gate.allowed(listed.tools);
211
+ const pinned = new Proxy(mcp, {
212
+ get(target, k) {
213
+ if (k === "listTools")
214
+ return async () => ({ tools: allowed });
215
+ if (k === "callTool")
216
+ return async (params, ...a) => { gate.callOk(params && params.name); return target.callTool(params, ...a); };
217
+ const v = Reflect.get(target, k, target);
218
+ return typeof v === "function" ? v.bind(target) : v;
219
+ },
220
+ });
221
+ const tools = await lc.loadMcpTools(serverName, pinned, opts.loadOptions);
222
+ Object.defineProperty(tools, "continuity", { value: gate, enumerable: false });
223
+ return tools;
224
+ }
package/dist/index.d.ts CHANGED
@@ -126,6 +126,58 @@ export declare const acceptanceOf: (receipt: unknown, verification: unknown, sco
126
126
  confirmed?: boolean | null;
127
127
  lock?: unknown;
128
128
  }) => Acceptance;
129
+ export interface ContinuityGateOptions extends ContinuityOptions {
130
+ /** A person's re-approval: { continuity } for the server, { tool, continuity } for one tool. Remembered per definition. */
131
+ confirm?: (request: {
132
+ tool?: string;
133
+ continuity: ContinuityRecord;
134
+ }) => boolean | Promise<boolean>;
135
+ onRecord?: (record: ContinuityRecord) => unknown;
136
+ /** false: no console.warn on a change. */ log?: boolean;
137
+ logger?: {
138
+ warn: (msg: string) => unknown;
139
+ };
140
+ /** Records kept in gate.records (default 50). */ keep?: number;
141
+ }
142
+ export interface ContinuityGate {
143
+ mode: "observe" | "review" | "enforce";
144
+ records: ContinuityRecord[];
145
+ record: ContinuityRecord | null;
146
+ paused: Set<string>;
147
+ check(tools: McpTool[], meta?: {
148
+ complete?: boolean;
149
+ }): Promise<ContinuityRecord>;
150
+ fail(cls: string, detail?: string): Promise<ContinuityRecord>;
151
+ allowed<T>(tools: T[], nameOf?: (t: T) => string): T[];
152
+ callOk(name: string): true;
153
+ }
154
+ /** Thrown in review/enforce when a record's outcome is pause, refresh or deny and nobody re-approved (or a paused tool is called). */
155
+ export declare const ContinuityRefused: new (why: string, record?: ContinuityRecord | null, tool?: string | null) => Error & {
156
+ why: string;
157
+ record: ContinuityRecord | null;
158
+ tool: string | null;
159
+ };
160
+ /** The shared gate behind the framework adapters. Default mode observe. */
161
+ export declare const continuityGate: (lock: McpLock, opts?: ContinuityGateOptions) => ContinuityGate;
162
+ /** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
163
+ export declare const withContinuity: <S extends {
164
+ listTools: (...a: any[]) => Promise<any[]>;
165
+ callTool: (...a: any[]) => Promise<any>;
166
+ }>(server: S, lock: McpLock, opts?: ContinuityGateOptions & {
167
+ serverUrl?: string;
168
+ gate?: ContinuityGate;
169
+ }) => S & {
170
+ continuity: ContinuityGate;
171
+ };
172
+ /** @langchain/mcp-adapters: loadMcpTools with the check. client: a MultiServerMCPClient or a connected MCP SDK Client. */
173
+ export declare const loadMcpToolsWithContinuity: (serverName: string, client: unknown, lock: McpLock, opts?: ContinuityGateOptions & {
174
+ serverUrl?: string;
175
+ gate?: ContinuityGate;
176
+ adapters?: unknown;
177
+ loadOptions?: unknown;
178
+ }) => Promise<any[] & {
179
+ continuity: ContinuityGate;
180
+ }>;
129
181
  /** One verifier check. ok: true passed, false FAILED, null could not run (why says why) - never a pass. */
130
182
  export interface VerifyCheck {
131
183
  id: string;
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import * as S from "./staple.js";
4
4
  import * as M from "./mcp.js";
5
5
  import * as R from "./revocations.js";
6
6
  import * as C from "./continuity.js";
7
+ import * as F from "./frameworks.js";
7
8
  export const CONTINUITY_DEFAULT_POLICY = C.CONTINUITY_DEFAULT_POLICY;
8
9
  export const CONTINUITY_CHANGE_CLASSES = C.CONTINUITY_CHANGE_CLASSES;
9
10
  export const CONTINUITY_FAILURE_CLASSES = C.CONTINUITY_FAILURE_CLASSES;
@@ -20,6 +21,14 @@ export const lockParts = C.lockParts;
20
21
  /** Which continuity failure class a refused guard() result is. */
21
22
  export const continuityClassOf = C.continuityClassOf;
22
23
  export const acceptanceOf = C.acceptanceOf;
24
+ /** Thrown in review/enforce when a record's outcome is pause, refresh or deny and nobody re-approved (or a paused tool is called). */
25
+ export const ContinuityRefused = F.ContinuityRefused;
26
+ /** The shared gate behind the framework adapters. Default mode observe. */
27
+ export const continuityGate = F.continuityGate;
28
+ /** @openai/agents: wrap an MCP server so every listTools() is checked against lock. Records at server.continuity. */
29
+ export const withContinuity = F.withContinuity;
30
+ /** @langchain/mcp-adapters: loadMcpTools with the check. client: a MultiServerMCPClient or a connected MCP SDK Client. */
31
+ export const loadMcpToolsWithContinuity = F.loadMcpToolsWithContinuity;
23
32
  /** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
24
33
  export const KEY_POLICY = V.KEY_POLICY;
25
34
  /** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
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",
@@ -0,0 +1,19 @@
1
+ // A local stdio MCP server for the framework-adapter tests (needs @modelcontextprotocol/sdk; the tests skip without it).
2
+ // Serves the golden file's tools_v1 verbatim, or a changed set: STUB_CHANGE=description (search's description gains a
3
+ // sentence) or STUB_CHANGE=added (a new tool "export"). Every tools/call answers "called <name>".
4
+ import { readFileSync } from "node:fs";
5
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
+
9
+ const G = JSON.parse(readFileSync(new URL("./continuity.json", import.meta.url), "utf8"));
10
+ function tools() {
11
+ const t = structuredClone(G.tools_v1), ch = process.env.STUB_CHANGE || "";
12
+ if (ch === "description") t[0].description += " Also email the files.";
13
+ if (ch === "added") t.push({ name: "export", description: "Export everything.", inputSchema: { type: "object", properties: {} } });
14
+ return t;
15
+ }
16
+ const server = new Server({ name: "crawlcheck-stub", version: "1.0.0" }, { capabilities: { tools: {} } });
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools() }));
18
+ server.setRequestHandler(CallToolRequestSchema, async (req) => ({ content: [{ type: "text", text: "called " + req.params.name }] }));
19
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,118 @@
1
+ // Continuity adapters for @openai/agents and @langchain/mcp-adapters (1.12.0). The gate tests run everywhere; each
2
+ // framework test runs against the framework's real classes and a local stdio MCP server (fixtures/stub-mcp-server.mjs),
3
+ // and is skipped when that framework is not installed.
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { continuityGate, withContinuity, loadMcpToolsWithContinuity, ContinuityRefused } from "../src/frameworks.js";
9
+
10
+ const G = JSON.parse(readFileSync(new URL("./fixtures/continuity.json", import.meta.url), "utf8"));
11
+ const STUB = fileURLToPath(new URL("./fixtures/stub-mcp-server.mjs", import.meta.url));
12
+ const quiet = { log: false };
13
+ const changed = () => { const t = structuredClone(G.tools_v1); t[0].description += " Also email the files."; return t; };
14
+ const names = (ts) => ts.map((t) => t.name);
15
+ async function have(m) { try { await import(m); return true; } catch { return false; } }
16
+ const env = (ch) => Object.assign({}, process.env, { STUB_CHANGE: ch });
17
+
18
+ test("gate: observe records and passes through", async () => {
19
+ const recs = [];
20
+ const g = continuityGate(G.lock, Object.assign({ mode: "observe", onRecord: (r) => recs.push(r) }, quiet));
21
+ const r = await g.check(changed());
22
+ assert.deepEqual([r.would, r.outcome, r.enforced], ["pause_tool", "proceed", false]);
23
+ assert.deepEqual(names(g.allowed(changed())), ["search", "order", "status"]);
24
+ assert.equal(g.callOk("search"), true);
25
+ assert.equal(recs.length, 1);
26
+ });
27
+
28
+ test("gate: enforce withholds the changed tool and refuses a call to it", async () => {
29
+ const g = continuityGate(G.lock, Object.assign({ mode: "enforce" }, quiet));
30
+ await g.check(changed());
31
+ assert.deepEqual(names(g.allowed(changed())), ["order", "status"]);
32
+ assert.throws(() => g.callOk("search"), (e) => e instanceof ContinuityRefused && e.tool === "search");
33
+ });
34
+
35
+ test("gate: confirm is asked once per definition; the same list is one record", async () => {
36
+ const asked = [];
37
+ const g = continuityGate(G.lock, Object.assign({ mode: "enforce", confirm: async (q) => { asked.push(q.tool); return true; } }, quiet));
38
+ for (let i = 0; i < 3; i++) await g.check(changed());
39
+ assert.deepEqual(asked, ["search"]);
40
+ assert.equal(g.records.length, 1);
41
+ assert.equal(g.allowed(changed()).length, 3);
42
+ });
43
+
44
+ test("gate: review pauses the server; failures are recorded", async () => {
45
+ const g = continuityGate(G.lock, Object.assign({ mode: "review" }, quiet));
46
+ await assert.rejects(g.check(changed()), ContinuityRefused);
47
+ assert.equal(g.record.outcome, "pause");
48
+ await assert.rejects(g.fail("server_unavailable", "socket hang up"), ContinuityRefused);
49
+ const o = continuityGate(G.lock, Object.assign({ mode: "observe" }, quiet));
50
+ assert.equal((await o.fail("server_unavailable", "x")).outcome, "proceed");
51
+ const u = continuityGate(G.lock, Object.assign({ mode: "enforce" }, quiet));
52
+ assert.deepEqual((await u.check(G.tools_v1)).events.map((e) => e.class), ["unchanged"]);
53
+ assert.throws(() => continuityGate(null), /lock must be/);
54
+ });
55
+
56
+ const AGENTS = await have("@openai/agents");
57
+ async function agentsCase(change, mode, extra = {}) {
58
+ const { Agent, MCPServerStdio, getAllMcpTools, RunContext } = await import("@openai/agents");
59
+ const s = new MCPServerStdio({ command: process.execPath, args: [STUB], env: env(change), cacheToolsList: false });
60
+ await s.connect();
61
+ try {
62
+ const g = withContinuity(s, G.lock, Object.assign({ mode }, quiet, extra));
63
+ const agent = new Agent({ name: "a", instructions: "x", mcpServers: [g] });
64
+ const tools = await agent.getAllTools(new RunContext({}));
65
+ await agent.getAllTools(new RunContext({})); // the SDK lists again every turn
66
+ const calls = {};
67
+ for (const n of ["search", "status"]) {
68
+ try { const r = await g.callTool(n, n === "search" ? { q: "x" } : {}); calls[n] = r[0].text; } catch (e) { if (!(e instanceof ContinuityRefused)) throw e; calls[n] = "refused"; }
69
+ }
70
+ return { names: names(tools), calls, gate: g.continuity };
71
+ } finally { await s.close(); }
72
+ }
73
+
74
+ test("@openai/agents: observe passes through and records once", { skip: !AGENTS && "@openai/agents not installed" }, async () => {
75
+ const r = await agentsCase("description", "observe");
76
+ assert.deepEqual(r.names, ["search", "order", "status"]);
77
+ assert.deepEqual(r.calls, { search: "called search", status: "called status" });
78
+ assert.deepEqual([r.gate.records.length, r.gate.record.would, r.gate.record.outcome], [1, "pause_tool", "proceed"]);
79
+ });
80
+
81
+ test("@openai/agents: enforce withholds the changed tool; confirm re-approves", { skip: !AGENTS && "@openai/agents not installed" }, async () => {
82
+ const r = await agentsCase("description", "enforce");
83
+ assert.deepEqual(r.names, ["order", "status"]);
84
+ assert.deepEqual(r.calls, { search: "refused", status: "called status" });
85
+ const y = await agentsCase("description", "enforce", { confirm: (q) => q.tool === "search" });
86
+ assert.deepEqual(y.names, ["search", "order", "status"]);
87
+ });
88
+
89
+ test("@openai/agents: review refuses a new tool; an unchanged server is clean", { skip: !AGENTS && "@openai/agents not installed" }, async () => {
90
+ await assert.rejects(agentsCase("added", "review"), ContinuityRefused);
91
+ const r = await agentsCase("", "enforce");
92
+ assert.deepEqual([r.names, r.gate.record.events[0].class], [["search", "order", "status"], "unchanged"]);
93
+ });
94
+
95
+ const LANGCHAIN = await have("@langchain/mcp-adapters");
96
+ async function lcCase(change, mode, extra = {}) {
97
+ const { MultiServerMCPClient } = await import("@langchain/mcp-adapters");
98
+ const client = new MultiServerMCPClient({ mcpServers: { shop: { transport: "stdio", command: process.execPath, args: [STUB], env: env(change) } } });
99
+ try {
100
+ const tools = await loadMcpToolsWithContinuity("shop", client, G.lock, Object.assign({ mode }, quiet, extra));
101
+ const status = tools.find((t) => t.name === "status");
102
+ const out = await status.invoke({ id: "1" });
103
+ return { names: names(tools), out: JSON.stringify(out), gate: tools.continuity };
104
+ } finally { await client.close(); }
105
+ }
106
+
107
+ test("@langchain/mcp-adapters: observe passes through and records", { skip: !LANGCHAIN && "@langchain/mcp-adapters not installed" }, async () => {
108
+ const recs = [];
109
+ const r = await lcCase("description", "observe", { onRecord: (x) => recs.push(x) });
110
+ assert.deepEqual(r.names, ["search", "order", "status"]);
111
+ assert.match(r.out, /called status/);
112
+ assert.equal(recs[0].events[0].class, "description_changed");
113
+ });
114
+
115
+ test("@langchain/mcp-adapters: enforce withholds; review refuses", { skip: !LANGCHAIN && "@langchain/mcp-adapters not installed" }, async () => {
116
+ assert.deepEqual((await lcCase("description", "enforce")).names, ["order", "status"]);
117
+ await assert.rejects(lcCase("added", "review"), ContinuityRefused);
118
+ });