@crawlcheck/sdk 1.11.0 → 1.13.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 +45 -0
- package/dist/continuity.d.ts +44 -0
- package/dist/continuity.js +136 -6
- package/dist/frameworks.d.ts +62 -0
- package/dist/frameworks.js +267 -0
- package/dist/index.d.ts +125 -5
- package/dist/index.js +18 -0
- package/dist/mcp.js +36 -3
- package/package.json +1 -1
- package/test/baseline.test.js +93 -0
- package/test/fixtures/continuity-gen.mjs +54 -4
- package/test/fixtures/continuity.json +5451 -131
- package/test/fixtures/key-directory-20261107.json +1 -0
- package/test/fixtures/lock-signed-noparts.json +1 -0
- package/test/fixtures/lock-signed.json +1 -0
- package/test/fixtures/stub-mcp-server.mjs +19 -0
- package/test/frameworks.test.js +118 -0
package/README.md
CHANGED
|
@@ -160,6 +160,51 @@ 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
|
+
|
|
182
|
+
### Check the baseline before trusting it (1.13.0)
|
|
183
|
+
|
|
184
|
+
Whoever can edit your approved lockfile can approve their own tools. So every gate (`withPreflight` with `lock`, `continuityGate`, `withContinuity`, `loadMcpToolsWithContinuity`) checks the lockfile's signature once, when it is created, before it compares anything against it:
|
|
185
|
+
|
|
186
|
+
| `baseline.trust` | meaning |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `verified` | signed by CrawlCheck (`cc.mcpLock(url)` / `GET /api/v1/mcp/lock`) with a CrawlCheck key. The package ships the published key ids (`CRAWLCHECK_LOCK_KIDS`); pass `keyPolicy: await cc.publishedKeyPolicy()` to follow a key rotation. These lockfiles carry `parts` inside the signature; `parts` added afterwards are not used. |
|
|
189
|
+
| `operator_signed` | you signed it with `signLock` and passed your public key in `operatorKeys`. Use this when you edit a lockfile yourself, for example to drop a tool. |
|
|
190
|
+
| `unsigned` | no signature. Accepted unless `requireSignedBaseline` (on by default once you pass `operatorKeys`). |
|
|
191
|
+
| `invalid` | the signature does not verify, the file was changed after signing, or the key is neither CrawlCheck's nor one of yours. |
|
|
192
|
+
|
|
193
|
+
`invalid`, or `unsigned` when a signed one is required, is the failure class `baseline_untrusted`: `enforce` and `review` refuse (`ContinuityRefused` / `McpPreflightRefused`), `observe` records it and goes on. `openContinuityGate(lock, opts)` throws at startup; `continuityGate` exposes `gate.ready` and `gate.baseline`. Every record names the result in `baseline.trust`. Nothing is verified per call.
|
|
194
|
+
|
|
195
|
+
Sign your own lockfile, with no command line:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
import { newOperatorKey, signLock, openContinuityGate } from "@crawlcheck/sdk";
|
|
199
|
+
|
|
200
|
+
const key = await newOperatorKey(); // once; store key.privateKey where the agent cannot read it
|
|
201
|
+
const signed = await signLock(lock, key.privateKey); // after you review (or edit) the lockfile; save signed
|
|
202
|
+
// at startup, with only the public key:
|
|
203
|
+
const gate = await openContinuityGate(signed, { mode: "enforce", operatorKeys: [key.publicKey] });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`verifyLockBaseline(lock, opts)` runs the same check on its own. The signature is Ed25519 over `"crawlcheck-lock-operator-v1\n" + sha256(canonical JSON of the lockfile without operator_signature)`; the Python package signs and verifies byte for byte the same.
|
|
207
|
+
|
|
163
208
|
## Do not trust the API: check the evidence yourself
|
|
164
209
|
|
|
165
210
|
```js
|
package/dist/continuity.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare const CONTINUITY_DEFAULT_POLICY: Readonly<{
|
|
|
27
27
|
check_timeout: "pause";
|
|
28
28
|
service_unavailable: "pause";
|
|
29
29
|
decision_refused: "deny";
|
|
30
|
+
baseline_untrusted: "deny";
|
|
30
31
|
}>;
|
|
31
32
|
}>;
|
|
32
33
|
/** Per-part hashes of one tool, so a change can be named (description, schema, annotations). */
|
|
@@ -63,6 +64,7 @@ export declare function continuityPolicy(p: any): Promise<{
|
|
|
63
64
|
check_timeout: "pause";
|
|
64
65
|
service_unavailable: "pause";
|
|
65
66
|
decision_refused: "deny";
|
|
67
|
+
baseline_untrusted: "deny";
|
|
66
68
|
}>;
|
|
67
69
|
allow_integrity_override: boolean;
|
|
68
70
|
} & {
|
|
@@ -103,3 +105,45 @@ export declare function acceptanceOf(receipt: any, verification: any, scope: any
|
|
|
103
105
|
};
|
|
104
106
|
/** Which failure class a refused guard() result is (for continuityFailure). */
|
|
105
107
|
export declare function continuityClassOf(g: any): "check_timeout" | "decision_refused" | "evidence_expired" | "key_retired" | "key_unknown" | "response_malformed" | "server_identity_mismatch" | "service_unavailable" | "signature_invalid";
|
|
108
|
+
/** CrawlCheck's lockfile signing keys (RFC 7638 thumbprints), as published at /.well-known/http-message-signatures-directory on 2026-11-07. */
|
|
109
|
+
export declare const CRAWLCHECK_LOCK_KIDS: readonly string[];
|
|
110
|
+
/** The operator's signature over the canonical lockfile (every field but operator_signature). privateKey: an Ed25519 private JWK ({kty, crv, x, d}). */
|
|
111
|
+
export declare function signLock(lock: any, privateKey: any): Promise<{
|
|
112
|
+
operator_signature: {
|
|
113
|
+
v: number;
|
|
114
|
+
alg: string;
|
|
115
|
+
kid: string;
|
|
116
|
+
message: string;
|
|
117
|
+
sig: string;
|
|
118
|
+
};
|
|
119
|
+
}>;
|
|
120
|
+
/** A new operator signing key: { privateKey (JWK, keep it out of the agent's reach), publicKey ({kty, crv, x}: pass it as operatorKeys) }. */
|
|
121
|
+
export declare function newOperatorKey(): Promise<{
|
|
122
|
+
privateKey: {
|
|
123
|
+
kty: string;
|
|
124
|
+
crv: string;
|
|
125
|
+
x: string | undefined;
|
|
126
|
+
d: string | undefined;
|
|
127
|
+
};
|
|
128
|
+
publicKey: {
|
|
129
|
+
kty: string;
|
|
130
|
+
crv: string;
|
|
131
|
+
x: string | undefined;
|
|
132
|
+
};
|
|
133
|
+
kid: string;
|
|
134
|
+
}>;
|
|
135
|
+
/**
|
|
136
|
+
* Is this baseline the one CrawlCheck or the operator signed? opts: operatorKeys (public JWKs or their x), keyPolicy,
|
|
137
|
+
* publishedKids, trustedKids. Returns { trust: verified | operator_signed | unsigned | invalid, signer, kid, why, parts_used,
|
|
138
|
+
* lock } where lock is the baseline to compare against (without unsigned parts). Offline; no network.
|
|
139
|
+
*/
|
|
140
|
+
export declare function verifyLockBaseline(lock: any, opts?: {}): Promise<{
|
|
141
|
+
trust: any;
|
|
142
|
+
signer: any;
|
|
143
|
+
kid: any;
|
|
144
|
+
why: any;
|
|
145
|
+
parts_used: boolean;
|
|
146
|
+
lock: any;
|
|
147
|
+
}>;
|
|
148
|
+
/** Why a baseline is refused (null when it is accepted). */
|
|
149
|
+
export declare function baselineRefusal(b: any, requireSigned: any): any;
|
package/dist/continuity.js
CHANGED
|
@@ -15,15 +15,19 @@
|
|
|
15
15
|
// enforce the policy's effects apply as written
|
|
16
16
|
// Effects, weakest to strongest: proceed, proceed_recorded (an approved exception), refresh, pause_tool, pause, deny.
|
|
17
17
|
// A failed exact comparison is never overridden by an interpretive assessment: nothing here reads descriptions for intent.
|
|
18
|
-
import { canon } from "./verify.js";
|
|
18
|
+
import { canon, keyPolicyCheck } from "./verify.js";
|
|
19
19
|
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
20
20
|
const enc = new TextEncoder();
|
|
21
21
|
const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
22
22
|
async function sha256Hex(s) { return hex(new Uint8Array(await subtle.digest("SHA-256", enc.encode(s)))); }
|
|
23
|
+
const b64d = (s) => { const t = String(s || "").replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(t.padEnd(Math.ceil(t.length / 4) * 4, "=")); const o = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++)
|
|
24
|
+
o[i] = bin.charCodeAt(i); return o; };
|
|
25
|
+
const b64e = (u8) => { let s = ""; for (const b of u8)
|
|
26
|
+
s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); };
|
|
23
27
|
export const CONTINUITY_EFFECTS = ["proceed", "proceed_recorded", "refresh", "pause_tool", "pause", "deny"];
|
|
24
28
|
export const CONTINUITY_CHANGE_CLASSES = ["tool_added", "tool_removed", "description_changed", "schema_changed", "annotations_changed", "tool_changed"];
|
|
25
|
-
export const CONTINUITY_FAILURE_CLASSES = ["evidence_expired", "signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch", "server_unavailable", "tool_list_truncated", "response_malformed", "baseline_stale", "policy_version_mismatch", "check_timeout", "service_unavailable", "decision_refused"];
|
|
26
|
-
const INTEGRITY = ["signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch"];
|
|
29
|
+
export const CONTINUITY_FAILURE_CLASSES = ["evidence_expired", "signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch", "server_unavailable", "tool_list_truncated", "response_malformed", "baseline_stale", "policy_version_mismatch", "check_timeout", "service_unavailable", "decision_refused", "baseline_untrusted"];
|
|
30
|
+
const INTEGRITY = ["signature_invalid", "key_unknown", "key_retired", "server_identity_mismatch", "baseline_untrusted"];
|
|
27
31
|
/** The default operator policy. Every value can be overridden; the record names the policy's sha256, so what decided is attributable. */
|
|
28
32
|
export const CONTINUITY_DEFAULT_POLICY = Object.freeze({
|
|
29
33
|
v: 1, id: "crawlcheck-continuity-default", max_baseline_age_days: 90,
|
|
@@ -31,7 +35,8 @@ export const CONTINUITY_DEFAULT_POLICY = Object.freeze({
|
|
|
31
35
|
unchanged: "proceed", tool_added: "pause_tool", tool_removed: "proceed", description_changed: "pause_tool", schema_changed: "pause_tool",
|
|
32
36
|
annotations_changed: "pause_tool", tool_changed: "pause_tool", evidence_expired: "refresh", signature_invalid: "deny", key_unknown: "deny",
|
|
33
37
|
key_retired: "deny", server_identity_mismatch: "deny", server_unavailable: "pause", tool_list_truncated: "pause", response_malformed: "pause",
|
|
34
|
-
baseline_stale: "pause", policy_version_mismatch: "pause", check_timeout: "pause", service_unavailable: "pause", decision_refused: "deny"
|
|
38
|
+
baseline_stale: "pause", policy_version_mismatch: "pause", check_timeout: "pause", service_unavailable: "pause", decision_refused: "deny",
|
|
39
|
+
baseline_untrusted: "deny"
|
|
35
40
|
})
|
|
36
41
|
});
|
|
37
42
|
const WHY = {
|
|
@@ -44,7 +49,8 @@ const WHY = {
|
|
|
44
49
|
tool_list_truncated: "the tool list is incomplete (a further page was not read)", response_malformed: "the tool list could not be parsed",
|
|
45
50
|
baseline_stale: "the approved baseline is older than the policy allows", policy_version_mismatch: "the policy in force is not the version the deployment expects",
|
|
46
51
|
check_timeout: "the check did not finish in time", service_unavailable: "the verification service could not be reached; the local fallback applies",
|
|
47
|
-
decision_refused: "CrawlCheck's signed decision for the server's host did not allow the connection"
|
|
52
|
+
decision_refused: "CrawlCheck's signed decision for the server's host did not allow the connection",
|
|
53
|
+
baseline_untrusted: "the approved baseline is not trusted: its signature does not verify, its key is neither CrawlCheck's nor a given operator key, or it is unsigned where a signed one is required"
|
|
48
54
|
};
|
|
49
55
|
/** Per-part hashes of one tool, so a change can be named (description, schema, annotations). */
|
|
50
56
|
export async function lockToolParts(t) {
|
|
@@ -138,7 +144,7 @@ export async function continuityCheck(lock, tools, opts = {}) {
|
|
|
138
144
|
const was = {};
|
|
139
145
|
(lock?.tools ?? []).forEach((r) => { was[r.name] = r.sha256; });
|
|
140
146
|
const approvedAt = Date.parse(lock?.approved_at ?? "");
|
|
141
|
-
rec.baseline = { tools_sha256: lock?.tools_sha256 ?? null, approved_at: lock?.approved_at ?? null, age_days: isFinite(approvedAt) ? Math.floor((now - approvedAt) / 864e5) : null, server: lock?.server?.url ?? null, per_part: !!(lock && lock.parts) };
|
|
147
|
+
rec.baseline = { tools_sha256: lock?.tools_sha256 ?? null, approved_at: lock?.approved_at ?? null, age_days: isFinite(approvedAt) ? Math.floor((now - approvedAt) / 864e5) : null, server: lock?.server?.url ?? null, per_part: !!(lock && lock.parts), trust: opts.baselineTrust ?? null };
|
|
142
148
|
rec.observed = { tools_sha256: listHash, count: rows.length, complete: opts.complete !== false };
|
|
143
149
|
if (opts.expectPolicySha256 && opts.expectPolicySha256 !== pol.sha256)
|
|
144
150
|
ev(rec, pol, mode, "policy_version_mismatch", null, X, now, "expected " + opts.expectPolicySha256 + ", in force " + pol.sha256);
|
|
@@ -282,3 +288,127 @@ export function continuityClassOf(g) {
|
|
|
282
288
|
return "response_malformed";
|
|
283
289
|
return "decision_refused";
|
|
284
290
|
}
|
|
291
|
+
// ── signed baseline (1.13.0) ───────────────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
// Whoever can edit the approved lockfile can approve their own tools. So the baseline's signature is checked once, when a
|
|
293
|
+
// gate is created (startup), before anything is compared against it:
|
|
294
|
+
// verified signed by CrawlCheck (/api/v1/mcp/lock, kind crawlcheck-mcp-lock, scheme crawlcheck-data-v1) with a
|
|
295
|
+
// CrawlCheck key: the pinned ids below, opts.publishedKids, or opts.keyPolicy (keyPolicyFrom /
|
|
296
|
+
// publishedKeyPolicy, which follows rotation). lock.parts added after signing is not covered: dropped.
|
|
297
|
+
// operator_signed signed with signLock() by a key in opts.operatorKeys (the operator's Ed25519 public keys).
|
|
298
|
+
// unsigned no signature. Accepted unless requireSignedBaseline (default: on when operatorKeys are given).
|
|
299
|
+
// invalid a signature that does not verify, a changed lockfile, or a key that is neither: baseline_untrusted.
|
|
300
|
+
/** CrawlCheck's lockfile signing keys (RFC 7638 thumbprints), as published at /.well-known/http-message-signatures-directory on 2026-11-07. */
|
|
301
|
+
export const CRAWLCHECK_LOCK_KIDS = Object.freeze(["1hHmE3fo1rg5Ig70wuFtJ5wBc7G-sUTgUJkxyPWFRps", "hcIAczGf-8EFBnkunmZlvFWnD2nHHeHeC9cM4BTiBVo"]);
|
|
302
|
+
const DATA_PREFIX = "crawlcheck-data-v1\n", OP_PREFIX = "crawlcheck-lock-operator-v1\n";
|
|
303
|
+
async function thumbOf(x) { return b64e(new Uint8Array(await subtle.digest("SHA-256", enc.encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: String(x) }))))); }
|
|
304
|
+
async function edVerify(x, msg, sig) {
|
|
305
|
+
try {
|
|
306
|
+
const k = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: String(x) }, { name: "Ed25519" }, false, ["verify"]);
|
|
307
|
+
return await subtle.verify({ name: "Ed25519" }, k, b64d(sig), enc.encode(msg));
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
return /not supported|unrecognized|unknown algorithm|NotSupported/i.test(String(e && (e.name + " " + e.message))) ? null : false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const without = (o, keys) => { const b = {}; for (const k of Object.keys(o))
|
|
314
|
+
if (keys.indexOf(k) < 0)
|
|
315
|
+
b[k] = o[k]; return b; };
|
|
316
|
+
const xOf = (k) => (typeof k === "string" ? k : k && typeof k.x === "string" ? k.x : null);
|
|
317
|
+
/** The operator's signature over the canonical lockfile (every field but operator_signature). privateKey: an Ed25519 private JWK ({kty, crv, x, d}). */
|
|
318
|
+
export async function signLock(lock, privateKey) {
|
|
319
|
+
if (!lock || typeof lock !== "object" || !Array.isArray(lock.tools))
|
|
320
|
+
throw new Error("lock must be an mcp-lockfile (an object with a tools array)");
|
|
321
|
+
const j = privateKey || {};
|
|
322
|
+
if (typeof j.d !== "string" || typeof j.x !== "string")
|
|
323
|
+
throw new Error("privateKey must be an Ed25519 private JWK with d and x (see newOperatorKey)");
|
|
324
|
+
const body = without(lock, ["operator_signature"]), message = OP_PREFIX + (await sha256Hex(canon(body)));
|
|
325
|
+
const k = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: j.x, d: j.d }, { name: "Ed25519" }, false, ["sign"]);
|
|
326
|
+
const sig = b64e(new Uint8Array(await subtle.sign({ name: "Ed25519" }, k, enc.encode(message))));
|
|
327
|
+
return Object.assign(body, { operator_signature: { v: 1, alg: "Ed25519", kid: await thumbOf(j.x), message, sig } });
|
|
328
|
+
}
|
|
329
|
+
/** A new operator signing key: { privateKey (JWK, keep it out of the agent's reach), publicKey ({kty, crv, x}: pass it as operatorKeys) }. */
|
|
330
|
+
export async function newOperatorKey() {
|
|
331
|
+
const kp = await subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]), j = await subtle.exportKey("jwk", kp.privateKey);
|
|
332
|
+
return { privateKey: { kty: "OKP", crv: "Ed25519", x: j.x, d: j.d }, publicKey: { kty: "OKP", crv: "Ed25519", x: j.x }, kid: await thumbOf(j.x) };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Is this baseline the one CrawlCheck or the operator signed? opts: operatorKeys (public JWKs or their x), keyPolicy,
|
|
336
|
+
* publishedKids, trustedKids. Returns { trust: verified | operator_signed | unsigned | invalid, signer, kid, why, parts_used,
|
|
337
|
+
* lock } where lock is the baseline to compare against (without unsigned parts). Offline; no network.
|
|
338
|
+
*/
|
|
339
|
+
export async function verifyLockBaseline(lock, opts = {}) {
|
|
340
|
+
const R = (trust, signer, kid, why, L) => ({ trust, signer, kid, why, parts_used: !!(L && L.parts && typeof L.parts === "object" && !Array.isArray(L.parts) && Object.keys(L.parts).length > 0), lock: L || lock });
|
|
341
|
+
if (!lock || typeof lock !== "object" || !Array.isArray(lock.tools))
|
|
342
|
+
return R("invalid", null, null, "the baseline is not an mcp-lockfile (an object with a tools array)", null);
|
|
343
|
+
const op = lock.operator_signature, okeys = (opts.operatorKeys || []).map(xOf).filter(Boolean);
|
|
344
|
+
if (op !== undefined && op !== null && (okeys.length || !lock.signature)) {
|
|
345
|
+
if (!okeys.length)
|
|
346
|
+
return R("invalid", null, null, "the baseline carries an operator signature, but no operator key was given to check it", null);
|
|
347
|
+
if (!op || typeof op !== "object" || op.alg !== "Ed25519" || typeof op.kid !== "string" || typeof op.sig !== "string" || typeof op.message !== "string")
|
|
348
|
+
return R("invalid", null, null, "the operator signature is malformed", null);
|
|
349
|
+
let x = null;
|
|
350
|
+
for (const k of okeys)
|
|
351
|
+
if ((await thumbOf(k)) === op.kid)
|
|
352
|
+
x = k;
|
|
353
|
+
if (!x)
|
|
354
|
+
return R("invalid", null, op.kid, "the operator signature is by key " + op.kid + ", which is not one of the operator keys given", null);
|
|
355
|
+
const sha = await sha256Hex(canon(without(lock, ["operator_signature"])));
|
|
356
|
+
if (op.message !== OP_PREFIX + sha)
|
|
357
|
+
return R("invalid", "operator", op.kid, "the baseline was changed after the operator signed it (it hashes to " + sha + ")", null);
|
|
358
|
+
const ok = await edVerify(x, op.message, op.sig);
|
|
359
|
+
if (ok === null)
|
|
360
|
+
return R("invalid", "operator", op.kid, "this runtime cannot verify Ed25519", null);
|
|
361
|
+
if (!ok)
|
|
362
|
+
return R("invalid", "operator", op.kid, "the operator signature does not verify", null);
|
|
363
|
+
return R("operator_signed", "operator", op.kid, "signed by operator key " + op.kid, lock);
|
|
364
|
+
}
|
|
365
|
+
const s = lock.signature;
|
|
366
|
+
if (s === undefined || s === null)
|
|
367
|
+
return R("unsigned", null, null, "the baseline carries no signature", lock);
|
|
368
|
+
if (lock.kind !== "crawlcheck-mcp-lock" || typeof s !== "object" || String(s.message || "").indexOf(DATA_PREFIX) !== 0)
|
|
369
|
+
return R("invalid", null, null, "the signature is not a CrawlCheck lockfile signature (kind crawlcheck-mcp-lock, scheme crawlcheck-data-v1)", null);
|
|
370
|
+
const kid = typeof s.kid === "string" ? s.kid : null;
|
|
371
|
+
let L = lock, sha = await sha256Hex(canon(without(lock, ["sha256", "signature", "operator_signature"])));
|
|
372
|
+
if (sha !== lock.sha256 && lock.parts !== undefined) {
|
|
373
|
+
const sha2 = await sha256Hex(canon(without(lock, ["sha256", "signature", "operator_signature", "parts"])));
|
|
374
|
+
if (sha2 === lock.sha256) {
|
|
375
|
+
sha = sha2;
|
|
376
|
+
L = without(lock, ["parts"]);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (sha !== lock.sha256)
|
|
380
|
+
return R("invalid", "crawlcheck", kid, "the baseline was changed after it was signed (it hashes to " + sha + ", the signature covers " + (lock.sha256 == null ? "null" : String(lock.sha256)) + ")", null);
|
|
381
|
+
if (s.message !== DATA_PREFIX + lock.sha256)
|
|
382
|
+
return R("invalid", "crawlcheck", kid, "the signature does not cover this baseline's digest", null);
|
|
383
|
+
const jx = s.key && s.key.jwk && s.key.jwk.x;
|
|
384
|
+
if (typeof jx !== "string")
|
|
385
|
+
return R("invalid", "crawlcheck", kid, "the signature carries no key", null);
|
|
386
|
+
if ((await thumbOf(jx)) !== kid)
|
|
387
|
+
return R("invalid", "crawlcheck", kid, "the signing key's thumbprint is not its stated key id", null);
|
|
388
|
+
const ok = await edVerify(jx, s.message, s.sig);
|
|
389
|
+
if (ok === null)
|
|
390
|
+
return R("invalid", "crawlcheck", kid, "this runtime cannot verify Ed25519", null);
|
|
391
|
+
if (!ok)
|
|
392
|
+
return R("invalid", "crawlcheck", kid, "the signature does not verify", null);
|
|
393
|
+
if (opts.keyPolicy != null) {
|
|
394
|
+
const kp = keyPolicyCheck(lock, opts.keyPolicy);
|
|
395
|
+
if (kp.ok !== true)
|
|
396
|
+
return R("invalid", "crawlcheck", kid, "the key policy rejects the signing key: " + kp.why, null);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
const ids = opts.publishedKids ?? opts.trustedKids ?? CRAWLCHECK_LOCK_KIDS;
|
|
400
|
+
if (ids.indexOf(kid) < 0)
|
|
401
|
+
return R("invalid", "crawlcheck", kid, "the baseline is signed by key " + kid + ", which is not a CrawlCheck key", null);
|
|
402
|
+
}
|
|
403
|
+
return R("verified", "crawlcheck", kid, "signed by CrawlCheck key " + kid + (L !== lock ? "; lock.parts was added after signing and is not used" : ""), L);
|
|
404
|
+
}
|
|
405
|
+
/** Why a baseline is refused (null when it is accepted). */
|
|
406
|
+
export function baselineRefusal(b, requireSigned) {
|
|
407
|
+
if (!b)
|
|
408
|
+
return "the baseline was not checked";
|
|
409
|
+
if (b.trust === "invalid")
|
|
410
|
+
return b.why;
|
|
411
|
+
if (b.trust === "unsigned" && requireSigned)
|
|
412
|
+
return "the baseline is unsigned and a signed one is required";
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
baseline: null;
|
|
14
|
+
ready: null;
|
|
15
|
+
readonly refused: any;
|
|
16
|
+
keepRecord(rec: any): Promise<any>;
|
|
17
|
+
ask(req: any): Promise<boolean>;
|
|
18
|
+
/** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
|
|
19
|
+
check(tools: any, meta?: {}): Promise<any>;
|
|
20
|
+
/** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
|
|
21
|
+
fail(cls: any, detail: any): Promise<any>;
|
|
22
|
+
/** The tools the agent may see: all of them in observe mode, else without the paused ones. */
|
|
23
|
+
allowed(tools: any, nameOf?: (t: any) => any): any;
|
|
24
|
+
/** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
|
|
25
|
+
callOk(name: any): boolean;
|
|
26
|
+
};
|
|
27
|
+
/** continuityGate, checked at startup: resolves to the gate once the baseline verifies, throws ContinuityRefused when it does not (outside observe). */
|
|
28
|
+
export declare function openContinuityGate(lock: any, opts?: {}): Promise<{
|
|
29
|
+
mode: any;
|
|
30
|
+
records: never[];
|
|
31
|
+
record: null;
|
|
32
|
+
paused: Set<any>;
|
|
33
|
+
baseline: null;
|
|
34
|
+
ready: null;
|
|
35
|
+
readonly refused: any;
|
|
36
|
+
keepRecord(rec: any): Promise<any>;
|
|
37
|
+
ask(req: any): Promise<boolean>;
|
|
38
|
+
/** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
|
|
39
|
+
check(tools: any, meta?: {}): Promise<any>;
|
|
40
|
+
/** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
|
|
41
|
+
fail(cls: any, detail: any): Promise<any>;
|
|
42
|
+
/** The tools the agent may see: all of them in observe mode, else without the paused ones. */
|
|
43
|
+
allowed(tools: any, nameOf?: (t: any) => any): any;
|
|
44
|
+
/** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
|
|
45
|
+
callOk(name: any): boolean;
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* OpenAI Agents SDK (@openai/agents): wrap an MCP server (MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE or your
|
|
49
|
+
* own MCPServer) so every listTools() the SDK makes is checked against lock. Everything else is the server's own.
|
|
50
|
+
* const guarded = withContinuity(server, lock, { mode: "observe" }); new Agent({ name, instructions, mcpServers: [guarded] })
|
|
51
|
+
* The records are at guarded.continuity.records.
|
|
52
|
+
*/
|
|
53
|
+
export declare function withContinuity(server: any, lock: any, opts?: {}): any;
|
|
54
|
+
/**
|
|
55
|
+
* LangChain (@langchain/mcp-adapters): loadMcpTools with the continuity check.
|
|
56
|
+
* const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" })
|
|
57
|
+
* client: a MultiServerMCPClient (its connection for serverName is used) or a connected MCP SDK Client. The list is read
|
|
58
|
+
* once (every page) and the LangChain tools are converted from exactly the definitions that were compared.
|
|
59
|
+
* opts.adapters: the @langchain/mcp-adapters module (default: imported on first use); opts.loadOptions: its
|
|
60
|
+
* LoadMcpToolsOptions. The gate (records) is opts.gate, or read them from opts.onRecord.
|
|
61
|
+
*/
|
|
62
|
+
export declare function loadMcpToolsWithContinuity(serverName: any, client: any, lock: any, opts?: {}): Promise<any>;
|
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
// 1.13.0 signed baseline: when the gate is created it checks the lockfile's signature (verifyLockBaseline) before trusting
|
|
14
|
+
// it. opts.operatorKeys, keyPolicy, publishedKids, trustedKids, requireSignedBaseline. A bad or forged signature, or an
|
|
15
|
+
// unsigned lockfile when requireSignedBaseline, is recorded as baseline_untrusted; outside observe every check then throws.
|
|
16
|
+
// await gate.ready (or use openContinuityGate) to fail at startup.
|
|
17
|
+
import { continuityCheck, continuityFailure, verifyLockBaseline, baselineRefusal } from "./continuity.js";
|
|
18
|
+
import { canon } from "./verify.js";
|
|
19
|
+
export class ContinuityRefused extends Error {
|
|
20
|
+
constructor(why, record = null, tool = null) {
|
|
21
|
+
super("CrawlCheck continuity refused" + (tool ? " tool " + tool : "") + ": " + why);
|
|
22
|
+
this.name = "ContinuityRefused";
|
|
23
|
+
this.why = why;
|
|
24
|
+
this.record = record;
|
|
25
|
+
this.tool = tool;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const pick = (t) => ({ name: t?.name, description: t?.description, inputSchema: t?.inputSchema ?? t?.input_schema, annotations: t?.annotations });
|
|
29
|
+
/** The shared gate. lock: an approved mcp-lockfile (with lock.parts so a change can be named). */
|
|
30
|
+
export function continuityGate(lock, opts = {}) {
|
|
31
|
+
const mode = opts.mode || "observe";
|
|
32
|
+
if (["observe", "review", "enforce"].indexOf(mode) < 0)
|
|
33
|
+
throw new Error("mode must be observe, review or enforce");
|
|
34
|
+
if (!lock || !Array.isArray(lock.tools))
|
|
35
|
+
throw new Error("lock must be an approved mcp-lockfile (an object with a tools array); see https://crawlcheck.io/spec/mcp-lockfile");
|
|
36
|
+
const keep = opts.keep ?? 50, okTools = new Set(), okLists = new Set();
|
|
37
|
+
let lastKey = null, base = lock, refused = null;
|
|
38
|
+
const co = { mode, policy: opts.policy, exceptions: opts.exceptions, server: opts.server ?? undefined, serverIdentity: opts.serverIdentity, expectPolicySha256: opts.expectPolicySha256 };
|
|
39
|
+
const requireSigned = opts.requireSignedBaseline ?? !!(opts.operatorKeys && opts.operatorKeys.length);
|
|
40
|
+
const gate = {
|
|
41
|
+
mode, records: [], record: null, paused: new Set(), baseline: null, ready: null,
|
|
42
|
+
get refused() { return refused; },
|
|
43
|
+
async keepRecord(rec) {
|
|
44
|
+
gate.record = rec;
|
|
45
|
+
gate.records = gate.records.concat([rec]).slice(-keep);
|
|
46
|
+
const changed = rec.events.some((e) => e.class !== "unchanged");
|
|
47
|
+
if (changed && opts.log !== false)
|
|
48
|
+
(opts.logger || console).warn("crawlcheck continuity " + rec.mode + " " + (rec.server || "-") + ": would " + rec.would + ", outcome " + rec.outcome + ": " + rec.why.join("; "));
|
|
49
|
+
if (opts.onRecord) {
|
|
50
|
+
try {
|
|
51
|
+
await opts.onRecord(rec);
|
|
52
|
+
}
|
|
53
|
+
catch (e) { /* recording never breaks the agent */ }
|
|
54
|
+
}
|
|
55
|
+
return rec;
|
|
56
|
+
},
|
|
57
|
+
async ask(req) {
|
|
58
|
+
if (!opts.confirm)
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
return !!(await opts.confirm(req));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
/** Compare the offered tools with the lock and apply the mode. Returns the record; throws ContinuityRefused. */
|
|
68
|
+
async check(tools, meta = {}) {
|
|
69
|
+
await gate.ready;
|
|
70
|
+
if (refused)
|
|
71
|
+
throw refused;
|
|
72
|
+
const complete = meta.complete !== false;
|
|
73
|
+
const rows = Array.isArray(tools) ? tools.map(pick) : tools;
|
|
74
|
+
let rec = await continuityCheck(base, rows, Object.assign({}, co, { complete, baselineTrust: gate.baseline && gate.baseline.trust }));
|
|
75
|
+
const key = (rec.observed?.tools_sha256 ?? "") + "|" + complete;
|
|
76
|
+
if (lastKey === key && gate.record)
|
|
77
|
+
rec = gate.record;
|
|
78
|
+
else {
|
|
79
|
+
lastKey = key;
|
|
80
|
+
await gate.keepRecord(rec);
|
|
81
|
+
}
|
|
82
|
+
if (mode === "observe") {
|
|
83
|
+
gate.paused = new Set();
|
|
84
|
+
return rec;
|
|
85
|
+
}
|
|
86
|
+
if (rec.outcome === "deny")
|
|
87
|
+
throw new ContinuityRefused(rec.why.join("; "), rec);
|
|
88
|
+
if (rec.outcome === "pause" || rec.outcome === "refresh") {
|
|
89
|
+
if (!okLists.has(key)) {
|
|
90
|
+
if (!(await gate.ask({ continuity: rec })))
|
|
91
|
+
throw new ContinuityRefused(rec.why.join("; "), rec);
|
|
92
|
+
okLists.add(key);
|
|
93
|
+
}
|
|
94
|
+
gate.paused = new Set();
|
|
95
|
+
return rec;
|
|
96
|
+
}
|
|
97
|
+
const byName = {};
|
|
98
|
+
for (const r of Array.isArray(rows) ? rows : [])
|
|
99
|
+
byName[String(r.name ?? "").slice(0, 120)] = canon(r);
|
|
100
|
+
const paused = new Set();
|
|
101
|
+
for (const n of rec.paused_tools) {
|
|
102
|
+
const k = n + "\n" + (byName[n] ?? "");
|
|
103
|
+
if (okTools.has(k) || (await gate.ask({ tool: n, continuity: rec })))
|
|
104
|
+
okTools.add(k);
|
|
105
|
+
else
|
|
106
|
+
paused.add(n);
|
|
107
|
+
}
|
|
108
|
+
gate.paused = paused;
|
|
109
|
+
return rec;
|
|
110
|
+
},
|
|
111
|
+
/** Record a check that could not compare anything. Throws ContinuityRefused outside observe. */
|
|
112
|
+
async fail(cls, detail) {
|
|
113
|
+
await gate.ready;
|
|
114
|
+
if (refused)
|
|
115
|
+
throw refused;
|
|
116
|
+
const rec = await continuityFailure(cls, Object.assign({}, co, { detail: detail ? String(detail).slice(0, 300) : undefined }));
|
|
117
|
+
lastKey = null;
|
|
118
|
+
await gate.keepRecord(rec);
|
|
119
|
+
if (mode !== "observe" && rec.outcome !== "proceed")
|
|
120
|
+
throw new ContinuityRefused(rec.why.join("; "), rec);
|
|
121
|
+
return rec;
|
|
122
|
+
},
|
|
123
|
+
/** The tools the agent may see: all of them in observe mode, else without the paused ones. */
|
|
124
|
+
allowed(tools, nameOf = (t) => t?.name) { return mode === "observe" || !gate.paused.size ? tools.slice() : tools.filter((t) => !gate.paused.has(nameOf(t))); },
|
|
125
|
+
/** Refuse a call to a paused tool (a framework that cached an earlier list could still try it). */
|
|
126
|
+
callOk(name) { if (mode !== "observe" && gate.paused.has(name))
|
|
127
|
+
throw new ContinuityRefused("tool " + name + " changed since it was approved (or is new) and no person re-approved it", gate.record, name); return true; },
|
|
128
|
+
};
|
|
129
|
+
gate.ready = (async () => {
|
|
130
|
+
let b;
|
|
131
|
+
try {
|
|
132
|
+
b = await verifyLockBaseline(lock, opts);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
b = { trust: "invalid", signer: null, kid: null, why: "the baseline could not be checked: " + (e && e.message || e), parts_used: false, lock };
|
|
136
|
+
}
|
|
137
|
+
gate.baseline = { trust: b.trust, signer: b.signer, kid: b.kid, why: b.why, parts_used: b.parts_used };
|
|
138
|
+
base = b.lock || lock;
|
|
139
|
+
const bad = baselineRefusal(b, requireSigned);
|
|
140
|
+
if (bad) {
|
|
141
|
+
const rec = await continuityFailure("baseline_untrusted", Object.assign({}, co, { detail: String(bad).slice(0, 300) }));
|
|
142
|
+
await gate.keepRecord(rec);
|
|
143
|
+
if (mode !== "observe" && rec.outcome !== "proceed")
|
|
144
|
+
refused = new ContinuityRefused(rec.why.join("; ") + ": " + bad, rec);
|
|
145
|
+
}
|
|
146
|
+
return gate.baseline;
|
|
147
|
+
})();
|
|
148
|
+
gate.ready.catch(() => { }); // a bad policy is thrown by check(), which awaits ready
|
|
149
|
+
return gate;
|
|
150
|
+
}
|
|
151
|
+
/** continuityGate, checked at startup: resolves to the gate once the baseline verifies, throws ContinuityRefused when it does not (outside observe). */
|
|
152
|
+
export async function openContinuityGate(lock, opts = {}) {
|
|
153
|
+
const g = continuityGate(lock, opts);
|
|
154
|
+
await g.ready;
|
|
155
|
+
if (g.refused)
|
|
156
|
+
throw g.refused;
|
|
157
|
+
return g;
|
|
158
|
+
}
|
|
159
|
+
const urlOf = (s) => {
|
|
160
|
+
for (const p of [s && s.params, s && s.underlying && s.underlying.params, s && s.options])
|
|
161
|
+
if (p && typeof p.url === "string" && p.url)
|
|
162
|
+
return p.url;
|
|
163
|
+
return null;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* OpenAI Agents SDK (@openai/agents): wrap an MCP server (MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE or your
|
|
167
|
+
* own MCPServer) so every listTools() the SDK makes is checked against lock. Everything else is the server's own.
|
|
168
|
+
* const guarded = withContinuity(server, lock, { mode: "observe" }); new Agent({ name, instructions, mcpServers: [guarded] })
|
|
169
|
+
* The records are at guarded.continuity.records.
|
|
170
|
+
*/
|
|
171
|
+
export function withContinuity(server, lock, opts = {}) {
|
|
172
|
+
const gate = opts.gate || continuityGate(lock, Object.assign({ server: opts.serverUrl ?? urlOf(server) ?? undefined }, opts));
|
|
173
|
+
const listTools = async (...a) => {
|
|
174
|
+
let tools;
|
|
175
|
+
try {
|
|
176
|
+
tools = await server.listTools(...a);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
await gate.fail("server_unavailable", e && e.message);
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
await gate.check(tools, { complete: true }); // the Agents SDK reads every page or throws
|
|
183
|
+
return gate.allowed(tools);
|
|
184
|
+
};
|
|
185
|
+
const callTool = async (name, ...a) => { gate.callOk(name); return server.callTool(name, ...a); };
|
|
186
|
+
const callToolResult = server.callToolResult ? async (name, ...a) => { gate.callOk(name); return server.callToolResult(name, ...a); } : undefined;
|
|
187
|
+
return new Proxy(server, {
|
|
188
|
+
get(target, k) {
|
|
189
|
+
if (k === "listTools")
|
|
190
|
+
return listTools;
|
|
191
|
+
if (k === "callTool")
|
|
192
|
+
return callTool;
|
|
193
|
+
if (k === "callToolResult" && callToolResult)
|
|
194
|
+
return callToolResult;
|
|
195
|
+
if (k === "continuity")
|
|
196
|
+
return gate;
|
|
197
|
+
const v = Reflect.get(target, k, target);
|
|
198
|
+
return typeof v === "function" ? v.bind(target) : v; // bound: the servers keep private (#) state
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async function listAll(client) {
|
|
203
|
+
const out = [];
|
|
204
|
+
let cursor, pages = 0;
|
|
205
|
+
for (;;) {
|
|
206
|
+
const r = await client.listTools(cursor ? { cursor } : undefined);
|
|
207
|
+
out.push(...(r?.tools ?? []));
|
|
208
|
+
const next = r?.nextCursor;
|
|
209
|
+
if (!next)
|
|
210
|
+
return { tools: out, complete: true };
|
|
211
|
+
if (++pages >= 1000 || next === cursor)
|
|
212
|
+
return { tools: out, complete: false };
|
|
213
|
+
cursor = next;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* LangChain (@langchain/mcp-adapters): loadMcpTools with the continuity check.
|
|
218
|
+
* const tools = await loadMcpToolsWithContinuity("shop", client, lock, { mode: "observe" })
|
|
219
|
+
* client: a MultiServerMCPClient (its connection for serverName is used) or a connected MCP SDK Client. The list is read
|
|
220
|
+
* once (every page) and the LangChain tools are converted from exactly the definitions that were compared.
|
|
221
|
+
* opts.adapters: the @langchain/mcp-adapters module (default: imported on first use); opts.loadOptions: its
|
|
222
|
+
* LoadMcpToolsOptions. The gate (records) is opts.gate, or read them from opts.onRecord.
|
|
223
|
+
*/
|
|
224
|
+
export async function loadMcpToolsWithContinuity(serverName, client, lock, opts = {}) {
|
|
225
|
+
const lc = opts.adapters || (await import("@langchain/mcp-adapters"));
|
|
226
|
+
let mcp = client;
|
|
227
|
+
if (client && typeof client.getClient === "function" && typeof client.callTool !== "function") {
|
|
228
|
+
mcp = await client.getClient(serverName);
|
|
229
|
+
if (!mcp)
|
|
230
|
+
throw new Error("no connected MCP server named " + serverName);
|
|
231
|
+
}
|
|
232
|
+
let conn = null;
|
|
233
|
+
try {
|
|
234
|
+
const c = client && client !== mcp ? client.config : null;
|
|
235
|
+
conn = c ? ((c.servers || c.mcpServers || {})[serverName] ?? null) : null;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
conn = null;
|
|
239
|
+
}
|
|
240
|
+
const gate = opts.gate || continuityGate(lock, Object.assign({ server: opts.serverUrl ?? (conn && conn.url) ?? undefined }, opts));
|
|
241
|
+
await gate.ready;
|
|
242
|
+
if (gate.refused)
|
|
243
|
+
throw gate.refused; // 1.13.0: an untrusted baseline fails before the server is read
|
|
244
|
+
let listed;
|
|
245
|
+
try {
|
|
246
|
+
listed = await listAll(mcp);
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
await gate.fail("server_unavailable", e && e.message);
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
await gate.check(structuredClone(listed.tools), { complete: listed.complete });
|
|
253
|
+
const allowed = gate.allowed(listed.tools);
|
|
254
|
+
const pinned = new Proxy(mcp, {
|
|
255
|
+
get(target, k) {
|
|
256
|
+
if (k === "listTools")
|
|
257
|
+
return async () => ({ tools: allowed });
|
|
258
|
+
if (k === "callTool")
|
|
259
|
+
return async (params, ...a) => { gate.callOk(params && params.name); return target.callTool(params, ...a); };
|
|
260
|
+
const v = Reflect.get(target, k, target);
|
|
261
|
+
return typeof v === "function" ? v.bind(target) : v;
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const tools = await lc.loadMcpTools(serverName, pinned, opts.loadOptions);
|
|
265
|
+
Object.defineProperty(tools, "continuity", { value: gate, enumerable: false });
|
|
266
|
+
return tools;
|
|
267
|
+
}
|