@astral-mcp/projection 0.7.0 → 0.8.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 +39 -19
- package/dist/index.d.mts +288 -267
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +617 -220
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -8
package/dist/index.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
|
|
3
3
|
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
4
4
|
import { getModuleLoader, getTypescriptSupport, loadQuickJs } from "@sebastianwessel/quickjs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { newVariant } from "quickjs-emscripten-core";
|
|
5
7
|
import { readFileSync, readdirSync } from "node:fs";
|
|
6
8
|
import { dirname, join, resolve } from "node:path";
|
|
7
9
|
import { InMemoryTransport, McpServer, ProtocolError, ProtocolErrorCode, acceptedContent, inputRequired, inputResponse, isInputRequiredResult } from "@modelcontextprotocol/server";
|
|
@@ -131,7 +133,7 @@ const MAX_CAPS = {
|
|
|
131
133
|
*/
|
|
132
134
|
const MIN_CAPS = {
|
|
133
135
|
segmentWallClockMs: 100,
|
|
134
|
-
wasmMemoryBytes:
|
|
136
|
+
wasmMemoryBytes: 16777216,
|
|
135
137
|
maxCodeBytes: 1024,
|
|
136
138
|
maxInputBytes: 1024,
|
|
137
139
|
maxReadCallsPerSegment: 1,
|
|
@@ -165,9 +167,15 @@ const BLOB_SCHEMA_VERSION = 1;
|
|
|
165
167
|
* CI instead of silently minting blobs with a stale version stamp.
|
|
166
168
|
*/
|
|
167
169
|
/** `@astral-mcp/projection` package version (must match packages/projection/package.json). */
|
|
168
|
-
const LIB_VERSION = "0.
|
|
170
|
+
const LIB_VERSION = "0.8.0";
|
|
169
171
|
/** Pinned XState version (must match the exact pin in package.json — D2). */
|
|
170
172
|
const XSTATE_VERSION = "5.32.5";
|
|
173
|
+
/**
|
|
174
|
+
* Exact pin of `@modelcontextprotocol/server` and `@modelcontextprotocol/client`
|
|
175
|
+
* (must match package.json). Not a blob stamp: the introspector names it when
|
|
176
|
+
* a wrapped `McpServer` comes from a second, different copy of the SDK.
|
|
177
|
+
*/
|
|
178
|
+
const MCP_SDK_VERSION = "2.3.1";
|
|
171
179
|
//#endregion
|
|
172
180
|
//#region src/blob/sealer.ts
|
|
173
181
|
/**
|
|
@@ -411,7 +419,7 @@ function openBlob(blob, options) {
|
|
|
411
419
|
}
|
|
412
420
|
if (!isRecord(payload)) throw verificationError("blob payload malformed: not a JSON object", HINT_CORRUPT);
|
|
413
421
|
if (payload["v"] !== 1) throw verificationError(`unknown blob schema version ${JSON.stringify(payload["v"])} (this library expects 1)`, HINT_VERSION);
|
|
414
|
-
if (payload["libv"] !== "0.
|
|
422
|
+
if (payload["libv"] !== "0.8.0") throw verificationError(`library version mismatch: blob was sealed by @astral-mcp/projection ${JSON.stringify(payload["libv"])}, this instance runs ${LIB_VERSION}`, HINT_VERSION);
|
|
415
423
|
if (payload["xstatev"] !== "5.32.5") throw verificationError(`xstate version mismatch: blob snapshot was produced under xstate ${JSON.stringify(payload["xstatev"])}, this instance runs ${XSTATE_VERSION}`, HINT_VERSION);
|
|
416
424
|
assertPayloadShape(payload);
|
|
417
425
|
const verified = payload;
|
|
@@ -638,24 +646,171 @@ const SANDBOX_GLOBAL = "__projection";
|
|
|
638
646
|
* module calls `env.__projection_toolCall(name, args)` and receives a promise.
|
|
639
647
|
*/
|
|
640
648
|
const TOOL_CALL_BINDING = "__projection_toolCall";
|
|
641
|
-
let
|
|
649
|
+
let variantPromise;
|
|
650
|
+
let wasmModulePromise;
|
|
642
651
|
let tsSupportPromise;
|
|
643
652
|
const QUICKJS_VARIANT = "@jitl/quickjs-ng-wasmfile-release-sync";
|
|
644
|
-
|
|
645
|
-
|
|
653
|
+
/** WebAssembly page size (bytes); memory limits are expressed in pages. */
|
|
654
|
+
const WASM_PAGE_BYTES = 65536;
|
|
655
|
+
/**
|
|
656
|
+
* Pages the variant's emscripten build starts with (`INITIAL_MEMORY`,
|
|
657
|
+
* 16 MB). An imported memory must be at least this large, so the cap can
|
|
658
|
+
* never bound the heap below it — `MIN_CAPS.wasmMemoryBytes` matches.
|
|
659
|
+
*/
|
|
660
|
+
const WASM_INITIAL_PAGES = 256;
|
|
661
|
+
function getQuickJsVariant() {
|
|
662
|
+
variantPromise ??= (async () => {
|
|
646
663
|
let variant;
|
|
647
664
|
try {
|
|
648
665
|
variant = await import(QUICKJS_VARIANT);
|
|
649
666
|
} catch (cause) {
|
|
650
|
-
|
|
667
|
+
variantPromise = void 0;
|
|
651
668
|
throw new ProjectionError("runtime", `QuickJS WASM variant '${QUICKJS_VARIANT}' is not installed`, {
|
|
652
669
|
hint: `Add ${QUICKJS_VARIANT} to the @astral-mcp/projection package dependencies`,
|
|
653
670
|
cause
|
|
654
671
|
});
|
|
655
672
|
}
|
|
656
|
-
return
|
|
673
|
+
return variant.default;
|
|
657
674
|
})();
|
|
658
|
-
return
|
|
675
|
+
return variantPromise;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Instances whose heap never grew, kept for reuse, keyed by `maximumPages`.
|
|
679
|
+
* See `releaseBoundedRuntime` for what qualifies.
|
|
680
|
+
*/
|
|
681
|
+
const idleRuntimes = /* @__PURE__ */ new Map();
|
|
682
|
+
let idleRuntimeCount = 0;
|
|
683
|
+
/** Idle instances kept across all caps; each holds ~16 MB of address space. */
|
|
684
|
+
const IDLE_RUNTIMES_MAX = 8;
|
|
685
|
+
/**
|
|
686
|
+
* The memory `maximum` (pages) for a cap. This is what makes `wasmMemoryBytes`
|
|
687
|
+
* a bound on real bytes. QuickJS's own `setMemoryLimit` cannot be: it charges
|
|
688
|
+
* each allocation `malloc_usable_size + overhead`, and the emscripten build
|
|
689
|
+
* returns 0 for `malloc_usable_size`, so the running total counts bookkeeping
|
|
690
|
+
* only and a loop of sub-cap allocations grows the heap to the module's 2 GB
|
|
691
|
+
* ceiling before "out of memory" fires (measured: ~2 GB under an 8 MB cap).
|
|
692
|
+
* The engine, by contrast, enforces a memory's `maximum` on every `grow`: the
|
|
693
|
+
* emscripten glue catches the failed grow, `malloc` returns NULL, and QuickJS
|
|
694
|
+
* throws its ordinary "out of memory" InternalError — which `classifyEvalError`
|
|
695
|
+
* reports as the memory `cap_exceeded`.
|
|
696
|
+
*
|
|
697
|
+
* Because the bound is a property of the memory, every session needs its own
|
|
698
|
+
* module instance rather than a share of one process-wide linear memory: a
|
|
699
|
+
* hoarding machine cannot starve its neighbours, the bound holds under
|
|
700
|
+
* concurrency without any per-runtime accounting, and a heap that grew can be
|
|
701
|
+
* reclaimed by dropping the instance (WebAssembly memories never shrink). The
|
|
702
|
+
* 16 MB `initial` is dictated by the WASM module's import.
|
|
703
|
+
*/
|
|
704
|
+
function maximumPagesFor(capBytes) {
|
|
705
|
+
return Math.max(WASM_INITIAL_PAGES, Math.ceil(capBytes / WASM_PAGE_BYTES));
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Compile the variant's WebAssembly binary once and share the compiled
|
|
709
|
+
* module across instances. Only instantiation has to happen per instance
|
|
710
|
+
* (that is what gives each its own memory); letting the emscripten glue read
|
|
711
|
+
* and compile the file every time costs ~2 ms more per instance with a
|
|
712
|
+
* ~25 ms tail. The binary sits next to the glue module. Where it cannot be
|
|
713
|
+
* located — no `import.meta.resolve` (bundler-transformed builds), or a
|
|
714
|
+
* bundler that moved the file — the glue's own loader is used instead:
|
|
715
|
+
* slower, otherwise equivalent.
|
|
716
|
+
*/
|
|
717
|
+
function getCompiledWasm() {
|
|
718
|
+
wasmModulePromise ??= (async () => {
|
|
719
|
+
if (typeof import.meta.resolve !== "function") return void 0;
|
|
720
|
+
try {
|
|
721
|
+
const glueUrl = import.meta.resolve(`${QUICKJS_VARIANT}/emscripten-module`);
|
|
722
|
+
const bytes = await readFile(new URL("emscripten-module.wasm", glueUrl));
|
|
723
|
+
return await WebAssembly.compile(bytes);
|
|
724
|
+
} catch {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
})();
|
|
728
|
+
return wasmModulePromise;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Take an idle instance for this cap, or instantiate a fresh one whose heap
|
|
732
|
+
* can never exceed `capBytes`.
|
|
733
|
+
*/
|
|
734
|
+
async function acquireBoundedRuntime(capBytes) {
|
|
735
|
+
const maximumPages = maximumPagesFor(capBytes);
|
|
736
|
+
const idle = idleRuntimes.get(maximumPages)?.pop();
|
|
737
|
+
if (idle !== void 0) {
|
|
738
|
+
idleRuntimeCount--;
|
|
739
|
+
return idle;
|
|
740
|
+
}
|
|
741
|
+
const memory = new WebAssembly.Memory({
|
|
742
|
+
initial: WASM_INITIAL_PAGES,
|
|
743
|
+
maximum: maximumPages
|
|
744
|
+
});
|
|
745
|
+
const [variant, wasmModule] = await Promise.all([getQuickJsVariant(), getCompiledWasm()]);
|
|
746
|
+
const runtime = await loadQuickJs(newVariant(variant, {
|
|
747
|
+
wasmMemory: memory,
|
|
748
|
+
...wasmModule !== void 0 ? { wasmModule } : {}
|
|
749
|
+
}));
|
|
750
|
+
guardHostMalloc(runtime.module);
|
|
751
|
+
return {
|
|
752
|
+
runtime,
|
|
753
|
+
memory,
|
|
754
|
+
maximumPages
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Make the instance's `_malloc` export throw when the heap is full instead
|
|
759
|
+
* of returning NULL. quickjs-emscripten copies every string and buffer it
|
|
760
|
+
* hands the VM — `evalCode` sources included, and the machine host delivers
|
|
761
|
+
* tool results as JSON inside evaluate source — through `_malloc` without
|
|
762
|
+
* checking the result, so under a bounded heap a NULL would make it write
|
|
763
|
+
* the copy at address 0 onward, corrupting the instance's static data
|
|
764
|
+
* (traps, assertion aborts, a poisoned pooled instance). Thrown instead, the
|
|
765
|
+
* error reaches `@sebastianwessel/quickjs`'s `handleEvalError` with the
|
|
766
|
+
* message "out of memory", which `classifyEvalError` reports as the memory
|
|
767
|
+
* `cap_exceeded`, and nothing is written. Only host-side allocations go
|
|
768
|
+
* through this export; QuickJS's own allocations stay inside the WASM module
|
|
769
|
+
* and fail the ordinary way.
|
|
770
|
+
*/
|
|
771
|
+
function guardHostMalloc(wasmModule) {
|
|
772
|
+
const emscripten = wasmModule.getFFI().module;
|
|
773
|
+
const malloc = emscripten._malloc;
|
|
774
|
+
emscripten._malloc = (size) => {
|
|
775
|
+
const ptr = malloc(size);
|
|
776
|
+
if (ptr === 0 && size > 0) {
|
|
777
|
+
const error = /* @__PURE__ */ new Error("out of memory");
|
|
778
|
+
error.stack = "";
|
|
779
|
+
throw error;
|
|
780
|
+
}
|
|
781
|
+
return ptr;
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Return an instance whose session has ended. An instance is kept only when
|
|
786
|
+
* its session was clean — every `evaluate`, the two boot evaluates included,
|
|
787
|
+
* returned ok — and its heap is still at its initial size. Such an instance's
|
|
788
|
+
* QuickJS runtime has been freed, so for the next session it is as good as
|
|
789
|
+
* new — and reusing it is what keeps segment start-up at the cost of the old
|
|
790
|
+
* shared singleton (instantiating per session doubled the resume leg under
|
|
791
|
+
* parallel load). Anything else is dropped: an instance that grew (a machine
|
|
792
|
+
* that hoarded, whether or not it hit the cap) so its memory is reclaimed,
|
|
793
|
+
* and an instance whose session saw any failure — cap exceeded, an in-VM
|
|
794
|
+
* exception, a thrown error — because a failure is the one place it might
|
|
795
|
+
* have been left inconsistent. The rule is deliberately that blunt rather
|
|
796
|
+
* than classifying failures: re-instantiation costs ~3-5 ms and failures are
|
|
797
|
+
* the exceptional path. (The heap-size check alone is not enough: at the
|
|
798
|
+
* 16 MB floor `maximum` equals `initial`, so a session that exhausted the
|
|
799
|
+
* heap never changes its size.) Sharing an instance across sessions in
|
|
800
|
+
* sequence is the same trust the shared singleton always placed in QuickJS's
|
|
801
|
+
* memory safety.
|
|
802
|
+
*/
|
|
803
|
+
function releaseBoundedRuntime(bounded, clean) {
|
|
804
|
+
if (!clean) return;
|
|
805
|
+
if (bounded.memory.buffer.byteLength !== WASM_INITIAL_PAGES * WASM_PAGE_BYTES) return;
|
|
806
|
+
if (idleRuntimeCount >= IDLE_RUNTIMES_MAX) return;
|
|
807
|
+
let bucket = idleRuntimes.get(bounded.maximumPages);
|
|
808
|
+
if (bucket === void 0) {
|
|
809
|
+
bucket = [];
|
|
810
|
+
idleRuntimes.set(bounded.maximumPages, bucket);
|
|
811
|
+
}
|
|
812
|
+
bucket.push(bounded);
|
|
813
|
+
idleRuntimeCount++;
|
|
659
814
|
}
|
|
660
815
|
/**
|
|
661
816
|
* Environment-class transpile failure: the TypeScript transpiler itself is
|
|
@@ -836,16 +991,19 @@ async function createSandboxSession(options) {
|
|
|
836
991
|
clear: silent
|
|
837
992
|
}
|
|
838
993
|
};
|
|
839
|
-
const
|
|
994
|
+
const bounded = await acquireBoundedRuntime(caps.wasmMemoryBytes);
|
|
995
|
+
const runtime = bounded.runtime;
|
|
840
996
|
const deadlineAt = Date.now() + caps.segmentWallClockMs;
|
|
841
997
|
const ready = deferred();
|
|
842
998
|
const hold = deferred();
|
|
843
999
|
let disposed = false;
|
|
1000
|
+
/** False once any evaluate fails; see `releaseBoundedRuntime`. */
|
|
1001
|
+
let clean = true;
|
|
844
1002
|
const runPromise = runtime.runSandboxed(async ({ evalCode }) => {
|
|
845
1003
|
ready.resolve({ evalCode });
|
|
846
1004
|
await hold.promise;
|
|
847
1005
|
}, sandboxOptions);
|
|
848
|
-
runPromise.
|
|
1006
|
+
runPromise.then(() => releaseBoundedRuntime(bounded, clean), () => {});
|
|
849
1007
|
const { evalCode } = await Promise.race([ready.promise, runPromise.then(() => {
|
|
850
1008
|
throw new ProjectionError("runtime", "QuickJS sandbox terminated during startup", {
|
|
851
1009
|
hint: "This is a sandbox runtime bug; check the host logs",
|
|
@@ -873,7 +1031,11 @@ async function createSandboxSession(options) {
|
|
|
873
1031
|
if (!result.ok) throw classifyEvalError(result, caps, logs.snapshot());
|
|
874
1032
|
return result.data;
|
|
875
1033
|
};
|
|
876
|
-
const
|
|
1034
|
+
const tracked = () => task().catch((error) => {
|
|
1035
|
+
clean = false;
|
|
1036
|
+
throw error;
|
|
1037
|
+
});
|
|
1038
|
+
const run = queue.then(tracked, tracked);
|
|
877
1039
|
queue = run.then(() => void 0, () => void 0);
|
|
878
1040
|
return run;
|
|
879
1041
|
};
|
|
@@ -951,6 +1113,29 @@ function indentBlock(text, indent = BLOCK_INDENT) {
|
|
|
951
1113
|
function inertIdentity$1(identity) {
|
|
952
1114
|
return JSON.stringify(neutralizeControlChars(capLength(identity, 200)));
|
|
953
1115
|
}
|
|
1116
|
+
/** Longest one-line tool purpose rendered anywhere (approval copy, overview, `search_tools`). */
|
|
1117
|
+
const MAX_PURPOSE_CHARS = 200;
|
|
1118
|
+
const MAX_RAW_DESCRIPTION_CHARS = 2e3;
|
|
1119
|
+
/**
|
|
1120
|
+
* The one-line human purpose: the first sentence of the server's
|
|
1121
|
+
* description, neutralized and capped. Runs of replacement characters
|
|
1122
|
+
* (neutralized control bytes) are collapsed so a hostile or malformed
|
|
1123
|
+
* description does not render as a wall of `�`. Undefined when the server
|
|
1124
|
+
* gave no usable description.
|
|
1125
|
+
*
|
|
1126
|
+
* One rule, three consumers — the approval copy (R16), the `discover_apis`
|
|
1127
|
+
* overview and `search_tools` (R33) — so every projection describes a tool
|
|
1128
|
+
* identically.
|
|
1129
|
+
*/
|
|
1130
|
+
function purposeSentence(description) {
|
|
1131
|
+
if (description === void 0) return void 0;
|
|
1132
|
+
description = capLength(description, MAX_RAW_DESCRIPTION_CHARS);
|
|
1133
|
+
const firstLine = description.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1134
|
+
if (firstLine.length === 0) return void 0;
|
|
1135
|
+
const match = /^.*?[.!?](\s|$)/.exec(firstLine);
|
|
1136
|
+
const neutralized = neutralizeControlChars((match ? match[0] : firstLine).trim()).replace(/�{2,}/g, "�").trim();
|
|
1137
|
+
return neutralized.length > 0 ? capLength(neutralized, MAX_PURPOSE_CHARS) : void 0;
|
|
1138
|
+
}
|
|
954
1139
|
//#endregion
|
|
955
1140
|
//#region src/policy/decision.ts
|
|
956
1141
|
/**
|
|
@@ -989,6 +1174,14 @@ function decidePolicy(entry, overrides) {
|
|
|
989
1174
|
}
|
|
990
1175
|
return entry.annotations?.readOnlyHint === true ? "auto" : "ask";
|
|
991
1176
|
}
|
|
1177
|
+
/** Map a policy decision to its plain-words posture (R33). Shared by every surface that names one. */
|
|
1178
|
+
function postureOf(decision) {
|
|
1179
|
+
switch (decision) {
|
|
1180
|
+
case "auto": return "runs immediately";
|
|
1181
|
+
case "ask": return "asks for approval";
|
|
1182
|
+
case "deny": return "blocked by policy";
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
992
1185
|
/**
|
|
993
1186
|
* Resolve a machine-requested tool call against the server catalog and
|
|
994
1187
|
* decide its policy.
|
|
@@ -1066,9 +1259,7 @@ const DEFAULT_MAX_ARGS_CHARS = 4096;
|
|
|
1066
1259
|
const MAX_STRING_LEAF_CHARS = 512;
|
|
1067
1260
|
const MAX_STATE_NAME_CHARS = 200;
|
|
1068
1261
|
const MAX_TOOL_NAME_CHARS = 200;
|
|
1069
|
-
const
|
|
1070
|
-
const MAX_RAW_DESCRIPTION_CHARS = 2e3;
|
|
1071
|
-
const DESTRUCTIVE_WARNING = "⚠ This can permanently change or delete data. Review the details before approving.";
|
|
1262
|
+
const DESTRUCTIVE_WARNING = "This can permanently change or delete data. Review the details before approving.";
|
|
1072
1263
|
const REDACTION_FAILED = "[redaction failed]";
|
|
1073
1264
|
/**
|
|
1074
1265
|
* Build the approval summary for a gated call.
|
|
@@ -1089,13 +1280,15 @@ function buildElicitationCopy(call, entry, options = {}) {
|
|
|
1089
1280
|
const safeName = neutralizeControlChars(capLength(entry.name, MAX_TOOL_NAME_CHARS));
|
|
1090
1281
|
const friendly = friendlyAction(entry, safeName);
|
|
1091
1282
|
const purpose = purposeSentence(entry.description);
|
|
1092
|
-
const
|
|
1283
|
+
const method = dispatchMethod(call.args, entry);
|
|
1284
|
+
const action = method !== void 0 ? `${friendly} — ${method}` : friendly;
|
|
1285
|
+
const title = destructive ? `⚠ Approve: ${action}` : `Approve: ${action}`;
|
|
1093
1286
|
const lines = [];
|
|
1094
1287
|
if (destructive) lines.push(DESTRUCTIVE_WARNING, "");
|
|
1095
1288
|
if (purpose !== void 0) lines.push(`What the server says it does: ${JSON.stringify(purpose)}`, "");
|
|
1096
1289
|
lines.push("Details:", indentBlock(neutralizeControlChars(renderedArgs, { allowNewline: true })));
|
|
1097
1290
|
const safeState = neutralizeControlChars(capLength(call.stateName, MAX_STATE_NAME_CHARS));
|
|
1098
|
-
lines.push("", options.origin === "direct" ? `Tool ${safeName}
|
|
1291
|
+
lines.push("", options.origin === "direct" ? `Tool: ${safeName}` : `Tool: ${safeName} · workflow step ${JSON.stringify(safeState)}`);
|
|
1099
1292
|
return {
|
|
1100
1293
|
title,
|
|
1101
1294
|
message: lines.join("\n"),
|
|
@@ -1105,6 +1298,31 @@ function buildElicitationCopy(call, entry, options = {}) {
|
|
|
1105
1298
|
destructive
|
|
1106
1299
|
};
|
|
1107
1300
|
}
|
|
1301
|
+
/** Top-level argument names that conventionally select a tool's sub-action. */
|
|
1302
|
+
const DISPATCH_ARGS = ["method", "action"];
|
|
1303
|
+
/**
|
|
1304
|
+
* The dispatch value of a method-style tool, when the call carries one and
|
|
1305
|
+
* the schema constrains it: a top-level `method` / `action` string argument
|
|
1306
|
+
* whose schema property has an `enum` listing that exact value. Only
|
|
1307
|
+
* enum-listed values qualify, so the suffix is server-defined vocabulary
|
|
1308
|
+
* (neutralized and capped like every other S5 string), never free text
|
|
1309
|
+
* the model typed. Every dispatch key the call carries must pass: one that
|
|
1310
|
+
* is present but not enum-listed means no suffix at all, so a rejected
|
|
1311
|
+
* `method` cannot be routed around with an `action` the model added.
|
|
1312
|
+
*/
|
|
1313
|
+
function dispatchMethod(args, entry) {
|
|
1314
|
+
const properties = entry.inputSchema["properties"];
|
|
1315
|
+
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) return;
|
|
1316
|
+
let suffix;
|
|
1317
|
+
for (const key of DISPATCH_ARGS) {
|
|
1318
|
+
if (!Object.hasOwn(args, key)) continue;
|
|
1319
|
+
const value = args[key];
|
|
1320
|
+
const property = properties[key];
|
|
1321
|
+
if (typeof value !== "string" || typeof property !== "object" || property === null || !Array.isArray(property["enum"]) || !property["enum"].includes(value)) return;
|
|
1322
|
+
suffix ??= neutralizeControlChars(capLength(value, MAX_TOOL_NAME_CHARS));
|
|
1323
|
+
}
|
|
1324
|
+
return suffix;
|
|
1325
|
+
}
|
|
1108
1326
|
/**
|
|
1109
1327
|
* Plain-English action label: the server's annotation title when present,
|
|
1110
1328
|
* otherwise the tool id humanized (namespace prefix dropped, separators to
|
|
@@ -1117,22 +1335,6 @@ function friendlyAction(entry, safeName) {
|
|
|
1117
1335
|
return humanized.length > 0 ? humanized : safeName;
|
|
1118
1336
|
}
|
|
1119
1337
|
/**
|
|
1120
|
-
* The one-line human purpose: the first sentence of the server's
|
|
1121
|
-
* description, neutralized and capped. Runs of replacement characters
|
|
1122
|
-
* (neutralized control bytes) are collapsed so a hostile or malformed
|
|
1123
|
-
* description does not render as a wall of `�`. Undefined when the server
|
|
1124
|
-
* gave no usable description.
|
|
1125
|
-
*/
|
|
1126
|
-
function purposeSentence(description) {
|
|
1127
|
-
if (description === void 0) return void 0;
|
|
1128
|
-
description = capLength(description, MAX_RAW_DESCRIPTION_CHARS);
|
|
1129
|
-
const firstLine = description.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1130
|
-
if (firstLine.length === 0) return void 0;
|
|
1131
|
-
const match = /^.*?[.!?](\s|$)/.exec(firstLine);
|
|
1132
|
-
const neutralized = neutralizeControlChars((match ? match[0] : firstLine).trim()).replace(/�{2,}/g, "�").trim();
|
|
1133
|
-
return neutralized.length > 0 ? capLength(neutralized, MAX_PURPOSE_CHARS) : void 0;
|
|
1134
|
-
}
|
|
1135
|
-
/**
|
|
1136
1338
|
* A deep copy of the arguments for the redaction hook. `structuredClone`
|
|
1137
1339
|
* rejects functions and symbols, which only a direct (non-wire) caller can
|
|
1138
1340
|
* put in args; fall back to the original there and let the fail-closed
|
|
@@ -1859,10 +2061,33 @@ var SegmentRunner = class {
|
|
|
1859
2061
|
* The arguments executed are the ones sealed in `pending` — the approval
|
|
1860
2062
|
* authorized those exact arguments, not whatever the restored machine
|
|
1861
2063
|
* might ask for now (S2).
|
|
2064
|
+
*
|
|
2065
|
+
* Policy outranks a stale approval (D9): a tool flipped to `deny` while
|
|
2066
|
+
* its blob was outstanding is denied here even if the human approved it —
|
|
2067
|
+
* "flip to deny" is the incident-response move, and the integrator's
|
|
2068
|
+
* policy must win over an approval minted under the old one.
|
|
1862
2069
|
*/
|
|
1863
2070
|
async applyVerdicts(pending, verdicts) {
|
|
1864
2071
|
const events = [];
|
|
1865
2072
|
for (const [key, call] of Object.entries(pending)) {
|
|
2073
|
+
const resolved = resolveCallPolicy(call, this.deps.catalog, this.deps.policy);
|
|
2074
|
+
if (!resolved.ok) throw resolved.error;
|
|
2075
|
+
if (resolved.decision === "deny") {
|
|
2076
|
+
const reason = policyDeniedReason(call.name);
|
|
2077
|
+
events.push({
|
|
2078
|
+
type: "TOOL_DENIED",
|
|
2079
|
+
callId: call.id,
|
|
2080
|
+
reason
|
|
2081
|
+
});
|
|
2082
|
+
this.deps.audit.push({
|
|
2083
|
+
callId: call.id,
|
|
2084
|
+
tool: call.name,
|
|
2085
|
+
args: call.args,
|
|
2086
|
+
decision: "denied_by_policy",
|
|
2087
|
+
reason
|
|
2088
|
+
});
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
1866
2091
|
const verdict = Object.hasOwn(verdicts, key) ? verdicts[key] : void 0;
|
|
1867
2092
|
if (verdict === void 0 || verdict.kind === "declined") {
|
|
1868
2093
|
const reason = verdict?.kind === "declined" ? verdict.reason : void 0;
|
|
@@ -2279,6 +2504,221 @@ function invalid(path, why) {
|
|
|
2279
2504
|
return new ProjectionError("validate", `call_tool args: ${path} ${why}`, { hint: "Pass args as plain JSON: objects, arrays, strings, finite numbers, booleans and null only." });
|
|
2280
2505
|
}
|
|
2281
2506
|
//#endregion
|
|
2507
|
+
//#region src/catalog/search.ts
|
|
2508
|
+
/**
|
|
2509
|
+
* `search_tools` — the catalog projection's discovery tool (PRD R33, S5).
|
|
2510
|
+
*
|
|
2511
|
+
* Returns one page of the wrapped server's tools — name, a one-line
|
|
2512
|
+
* purpose, the policy posture in plain words, and whether the server marks
|
|
2513
|
+
* the tool destructive — optionally matched against a query. It is the lazy
|
|
2514
|
+
* alternative to `discover_apis`' inline overview: O(tools the model looks
|
|
2515
|
+
* at), not O(tools the server has).
|
|
2516
|
+
*
|
|
2517
|
+
* Matching is token-based (DECISIONS D10): the query is split into words on
|
|
2518
|
+
* anything that is not a letter, combining mark, or digit, and a tool
|
|
2519
|
+
* matches when ANY word is a case-insensitive substring of its name, its
|
|
2520
|
+
* annotation title, or its full description. Each word scores +2 when it is
|
|
2521
|
+
* in the name and, independently, +1 when it is in the title or description
|
|
2522
|
+
* (a word in both scores 3); matches are ranked by score, ties in catalog
|
|
2523
|
+
* order, so a guessed name like `create_issue` finds `issue_write` instead
|
|
2524
|
+
* of nothing. A title hit counts like a description hit — it catches words
|
|
2525
|
+
* the description lacks — but never outweighs a name hit, so "pull request"
|
|
2526
|
+
* lists the `*pull_request*` tools before a tool whose title merely mentions
|
|
2527
|
+
* them. No regex is ever built from the query (S5: descriptions are hostile
|
|
2528
|
+
* input).
|
|
2529
|
+
*
|
|
2530
|
+
* Everything rendered comes from the S5-untrusted catalog, so names and
|
|
2531
|
+
* purposes are neutralized in the text rendering. `structuredContent.tools[].name`
|
|
2532
|
+
* stays verbatim: it is the key the model hands back to `describe_tool` and
|
|
2533
|
+
* `call_tool`, and JSON escaping already renders it inert there.
|
|
2534
|
+
*/
|
|
2535
|
+
/** Page size — a transport bound, independent of the R37 inline threshold. */
|
|
2536
|
+
const DEFAULT_SEARCH_PAGE_SIZE = 50;
|
|
2537
|
+
const MAX_PAGE_SIZE = 200;
|
|
2538
|
+
/** `query` is capped (R33): its words are matched as plain substrings, never a regex. */
|
|
2539
|
+
const MAX_QUERY_CHARS = 200;
|
|
2540
|
+
/** Summarize one catalog entry (R33). */
|
|
2541
|
+
function summarizeTool(entry, policy) {
|
|
2542
|
+
return {
|
|
2543
|
+
name: entry.name,
|
|
2544
|
+
purpose: purposeSentence(entry.description) ?? "(no description)",
|
|
2545
|
+
posture: postureOf(decidePolicy(entry, policy)),
|
|
2546
|
+
destructive: entry.annotations?.destructiveHint === true
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Run `search_tools`. Throws a `validate`-phase {@link ProjectionError} for a
|
|
2551
|
+
* malformed cursor, one issued for a different query, or one pointing past
|
|
2552
|
+
* the end of the results (R33).
|
|
2553
|
+
*/
|
|
2554
|
+
function searchTools(catalog, args, options = {}) {
|
|
2555
|
+
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, options.pageSize ?? 50));
|
|
2556
|
+
const rawQuery = args.query ?? "";
|
|
2557
|
+
const query = rawQuery.slice(0, 200);
|
|
2558
|
+
const queryTruncated = rawQuery.length > 200;
|
|
2559
|
+
const offset = decodeCursor(args.cursor, query);
|
|
2560
|
+
const matches = rankTools(dedupeTools(catalog.tools), query);
|
|
2561
|
+
if (offset > 0 && offset >= matches.length) throw cursorError("cursor is past the end of the results");
|
|
2562
|
+
const slice = matches.slice(offset, offset + pageSize);
|
|
2563
|
+
const tools = slice.map((entry) => summarizeTool(entry, options.policy));
|
|
2564
|
+
const end = offset + slice.length;
|
|
2565
|
+
const page = {
|
|
2566
|
+
tools,
|
|
2567
|
+
total: matches.length,
|
|
2568
|
+
...end < matches.length ? { nextCursor: encodeCursor(query, end) } : {}
|
|
2569
|
+
};
|
|
2570
|
+
return {
|
|
2571
|
+
content: [{
|
|
2572
|
+
type: "text",
|
|
2573
|
+
text: renderPage(catalog, page, query, offset, queryTruncated)
|
|
2574
|
+
}],
|
|
2575
|
+
structuredContent: { ...page }
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* The declared tools closest to a name the catalog does not have: the
|
|
2580
|
+
* name's tokens (`create_issue` → `create`, `issue`) ranked exactly as a
|
|
2581
|
+
* `search_tools` query is (D10), kept only when at least one token hit the
|
|
2582
|
+
* tool's NAME — a description-only hit (`directory` → `list_dir`) is too
|
|
2583
|
+
* loose to put in front of the model as "did you mean" — top `limit`.
|
|
2584
|
+
* Empty when nothing qualifies.
|
|
2585
|
+
*
|
|
2586
|
+
* The name is cut at `MAX_QUERY_CHARS` before ranking, the same R33 cap
|
|
2587
|
+
* `searchTools` applies to its query, so an unknown-name `call_tool` costs
|
|
2588
|
+
* what a `search_tools` call costs however long the guessed name is (the
|
|
2589
|
+
* catalog lookup it failed was an O(1) map miss; this is the first thing
|
|
2590
|
+
* that scans the catalog with the name).
|
|
2591
|
+
*/
|
|
2592
|
+
function suggestTools(catalog, name, limit = 3) {
|
|
2593
|
+
const query = name.slice(0, 200);
|
|
2594
|
+
if (tokenize(query).length === 0) return [];
|
|
2595
|
+
return scoreTools(dedupeTools(catalog.tools), query).filter((item) => item.nameHits > 0).slice(0, Math.max(0, limit)).map((item) => item.entry.name);
|
|
2596
|
+
}
|
|
2597
|
+
/** A tool name — the caller's or the catalog's — quoted for an error (S5). */
|
|
2598
|
+
function inertName(text) {
|
|
2599
|
+
return JSON.stringify(capLength(neutralizeControlChars(text), 200));
|
|
2600
|
+
}
|
|
2601
|
+
/** `"a"`, `"a" or "b"`, `"a", "b" or "c"`. */
|
|
2602
|
+
function listNames(names) {
|
|
2603
|
+
if (names.length <= 1) return names.join("");
|
|
2604
|
+
return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1] ?? ""}`;
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* The `validate` error for a tool name the catalog does not declare, from
|
|
2608
|
+
* `call_tool` or `describe_tool` (R34, R35): names the closest declared
|
|
2609
|
+
* tools when there are any, then points at `search_tools`. A model that
|
|
2610
|
+
* guessed `create_issue` learns `issue_write` in the same turn instead of
|
|
2611
|
+
* three turns later. Every name shown is S5-untrusted: neutralized, capped
|
|
2612
|
+
* and quoted.
|
|
2613
|
+
*/
|
|
2614
|
+
function unknownToolError(catalog, name, via) {
|
|
2615
|
+
const suggestions = suggestTools(catalog, name).map(inertName);
|
|
2616
|
+
const didYouMean = suggestions.length === 0 ? "" : `Did you mean ${listNames(suggestions)}? `;
|
|
2617
|
+
return new ProjectionError("validate", `Unknown tool ${inertName(name)}: the wrapped server does not declare it`, { hint: `${didYouMean}Call search_tools to list the tools this server declares, then ${via} with one of those names exactly.` });
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Tokens of a query: lowercased, NFC-normalized, split on anything that is
|
|
2621
|
+
* not a letter, combining mark, or digit — so a decomposed `é` stays inside
|
|
2622
|
+
* its word and matches a precomposed one in the catalog. Tokens under two
|
|
2623
|
+
* characters are dropped; when nothing survives, the whole trimmed query is
|
|
2624
|
+
* the one token.
|
|
2625
|
+
*/
|
|
2626
|
+
function tokenize(query) {
|
|
2627
|
+
const lowered = query.toLowerCase().normalize("NFC");
|
|
2628
|
+
const tokens = lowered.split(/[^\p{L}\p{M}\p{N}]+/u).filter((token) => token.length >= 2);
|
|
2629
|
+
if (tokens.length > 0) return tokens;
|
|
2630
|
+
const whole = lowered.trim();
|
|
2631
|
+
return whole.length > 0 ? [whole] : [];
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Filter and rank the catalog for a query (D10). An empty query returns
|
|
2635
|
+
* every tool in catalog order; a query with no token at all (whitespace
|
|
2636
|
+
* only) matches nothing.
|
|
2637
|
+
*/
|
|
2638
|
+
function rankTools(tools, query) {
|
|
2639
|
+
if (query.length === 0) return tools;
|
|
2640
|
+
return scoreTools(tools, query).map((item) => item.entry);
|
|
2641
|
+
}
|
|
2642
|
+
/**
|
|
2643
|
+
* The tools a query matches, best first (D10): a token scores +2 in the
|
|
2644
|
+
* name and, independently, +1 in the title or description. The sort is
|
|
2645
|
+
* stable, so equal scores keep catalog order and paging stays deterministic
|
|
2646
|
+
* for a given query. A query with no token matches nothing.
|
|
2647
|
+
*/
|
|
2648
|
+
function scoreTools(tools, query) {
|
|
2649
|
+
const tokens = new Set(tokenize(query));
|
|
2650
|
+
if (tokens.size === 0) return [];
|
|
2651
|
+
const scored = [];
|
|
2652
|
+
for (const entry of tools) {
|
|
2653
|
+
const name = entry.name.toLowerCase();
|
|
2654
|
+
const title = (entry.annotations?.title ?? "").toLowerCase();
|
|
2655
|
+
const description = (entry.description ?? "").toLowerCase();
|
|
2656
|
+
let nameHits = 0;
|
|
2657
|
+
let score = 0;
|
|
2658
|
+
for (const token of tokens) {
|
|
2659
|
+
if (name.includes(token)) {
|
|
2660
|
+
nameHits += 1;
|
|
2661
|
+
score += 2;
|
|
2662
|
+
}
|
|
2663
|
+
if (title.includes(token) || description.includes(token)) score += 1;
|
|
2664
|
+
}
|
|
2665
|
+
if (score > 0) scored.push({
|
|
2666
|
+
entry,
|
|
2667
|
+
score,
|
|
2668
|
+
nameHits
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
return scored.sort((a, b) => b.score - a.score);
|
|
2672
|
+
}
|
|
2673
|
+
function renderPage(catalog, page, query, offset, queryTruncated) {
|
|
2674
|
+
const lines = [];
|
|
2675
|
+
if (queryTruncated) lines.push(`(query truncated to its first 200 characters)`, "");
|
|
2676
|
+
const scope = query.length > 0 ? ` matching ${JSON.stringify(neutralizeControlChars(query))}` : "";
|
|
2677
|
+
if (page.tools.length === 0) lines.push(`No tools${scope} on server ${inertIdentity$1(catalog.identity)}.`);
|
|
2678
|
+
else {
|
|
2679
|
+
lines.push(`Tools ${offset + 1}–${offset + page.tools.length} of ${page.total}${scope} (server ${inertIdentity$1(catalog.identity)}):`);
|
|
2680
|
+
for (const tool of page.tools) {
|
|
2681
|
+
const name = capLength(neutralizeControlChars(tool.name), 200);
|
|
2682
|
+
const flags = tool.destructive ? `${tool.posture}, destructive` : tool.posture;
|
|
2683
|
+
lines.push(`- ${name} — ${tool.purpose} [${flags}]`);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
if (page.nextCursor !== void 0) lines.push("", `More tools follow: call search_tools again with cursor ${JSON.stringify(page.nextCursor)}.`);
|
|
2687
|
+
lines.push("", "Call describe_tool with a name before calling a tool whose arguments you have not seen.");
|
|
2688
|
+
return lines.join("\n");
|
|
2689
|
+
}
|
|
2690
|
+
function encodeCursor(query, offset) {
|
|
2691
|
+
const payload = {
|
|
2692
|
+
q: query,
|
|
2693
|
+
o: offset
|
|
2694
|
+
};
|
|
2695
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
2696
|
+
}
|
|
2697
|
+
function cursorError(why) {
|
|
2698
|
+
return new ProjectionError("validate", `search_tools cursor is not usable: ${why}`, { hint: "Pass a cursor exactly as a previous search_tools result returned it, with the same query; or omit it to start from the first page." });
|
|
2699
|
+
}
|
|
2700
|
+
/**
|
|
2701
|
+
* Decode a cursor to its offset. Only the fields read (`q`, `o`) are
|
|
2702
|
+
* validated: a payload carrying extra keys is accepted, since the cursor is
|
|
2703
|
+
* opaque data and nothing else in it is consumed. The offset's upper bound
|
|
2704
|
+
* is checked by the caller once the match count is known.
|
|
2705
|
+
*/
|
|
2706
|
+
function decodeCursor(cursor, query) {
|
|
2707
|
+
if (cursor === void 0 || cursor.length === 0) return 0;
|
|
2708
|
+
if (cursor.length > 1024) throw cursorError("too long");
|
|
2709
|
+
let payload;
|
|
2710
|
+
try {
|
|
2711
|
+
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
2712
|
+
} catch {
|
|
2713
|
+
throw cursorError("malformed");
|
|
2714
|
+
}
|
|
2715
|
+
if (typeof payload !== "object" || payload === null || typeof payload.q !== "string" || typeof payload.o !== "number") throw cursorError("malformed");
|
|
2716
|
+
const { q, o } = payload;
|
|
2717
|
+
if (!Number.isInteger(o) || o < 0) throw cursorError("malformed");
|
|
2718
|
+
if (q !== query) throw cursorError("it was issued for a different query");
|
|
2719
|
+
return o;
|
|
2720
|
+
}
|
|
2721
|
+
//#endregion
|
|
2282
2722
|
//#region src/catalog/validate-args.ts
|
|
2283
2723
|
/**
|
|
2284
2724
|
* Structural argument validation for `call_tool` (PRD R35.1).
|
|
@@ -2378,7 +2818,7 @@ async function callTool(args, deps) {
|
|
|
2378
2818
|
const toolArgs = normalizeArgs(args.args, caps);
|
|
2379
2819
|
if (args.requestState !== void 0) return await resume(args, toolArgs, deps, caps);
|
|
2380
2820
|
const entry = findTool(deps.catalog, args.name);
|
|
2381
|
-
if (entry === void 0) throw
|
|
2821
|
+
if (entry === void 0) throw unknownToolError(deps.catalog, args.name, "call_tool");
|
|
2382
2822
|
validateArgsShape(entry.name, toolArgs, entry.inputSchema);
|
|
2383
2823
|
const request = canonicalRequest(entry.name, toolArgs);
|
|
2384
2824
|
switch (decidePolicy(entry, deps.policy)) {
|
|
@@ -2407,9 +2847,6 @@ function normalizeArgs(raw, caps) {
|
|
|
2407
2847
|
if (size > caps.maxInputBytes) throw new ProjectionError("cap_exceeded", `call_tool args serialize to ${size} bytes, exceeding the limit of ${caps.maxInputBytes}`, { hint: `Shrink the arguments to at most ${caps.maxInputBytes} bytes of JSON.` });
|
|
2408
2848
|
return raw;
|
|
2409
2849
|
}
|
|
2410
|
-
function unknownTool(name) {
|
|
2411
|
-
return new ProjectionError("validate", `Unknown tool ${inert(name)}: the wrapped server does not declare it`, { hint: "Call search_tools to list the tools this server declares, then call_tool with one of those names exactly." });
|
|
2412
|
-
}
|
|
2413
2850
|
function suspend(entry, toolArgs, request, deps) {
|
|
2414
2851
|
const gate = legacySuspendError(deps, {
|
|
2415
2852
|
subject: "this call",
|
|
@@ -2463,7 +2900,7 @@ async function resume(args, toolArgs, deps, caps) {
|
|
|
2463
2900
|
await assertNotReplayed(payload, deps.replayCache);
|
|
2464
2901
|
const recorded = payload.pending[CALL_KEY];
|
|
2465
2902
|
const entry = findTool(deps.catalog, recorded.name);
|
|
2466
|
-
if (entry === void 0) throw
|
|
2903
|
+
if (entry === void 0) throw unknownToolError(deps.catalog, recorded.name, "call_tool");
|
|
2467
2904
|
if (decidePolicy(entry, deps.policy) === "deny") return denied(entry);
|
|
2468
2905
|
const view = inputResponse(args.inputResponses, CALL_KEY);
|
|
2469
2906
|
if (view.kind !== "elicit" || view.action !== "accept") {
|
|
@@ -2526,7 +2963,12 @@ function outcome(value) {
|
|
|
2526
2963
|
}
|
|
2527
2964
|
function renderOutcome(value) {
|
|
2528
2965
|
switch (value.status) {
|
|
2529
|
-
case "ok":
|
|
2966
|
+
case "ok": {
|
|
2967
|
+
const head = `call_tool ${inert(value.tool)}: ok`;
|
|
2968
|
+
const body = neutralizeControlChars(typeof value.output === "string" ? value.output : JSON.stringify(value.output, null, 2) ?? "null", { allowNewline: true });
|
|
2969
|
+
if (value.truncated === true) return `${head} (result truncated)\n\n${body}\n\nThe call ran, but its result is ${value.outputBytes} bytes, over this server's result limit; the output above is the first ${RESULT_PREVIEW_CHARS} characters of its JSON. Retrying returns the same preview — work from it, or call the tool with arguments that return less.`;
|
|
2970
|
+
return `${head}\n\n${body}`;
|
|
2971
|
+
}
|
|
2530
2972
|
case "declined": return `The user declined ${inert(value.tool)}${value.reason !== void 0 ? ` — ${JSON.stringify(value.reason)}` : ""}. Nothing was executed. Do not retry this call unless the user asks for it.`;
|
|
2531
2973
|
case "denied_by_policy": return `${inert(value.tool)} is blocked by this server's policy. Nothing was executed; the policy is not something you can change.`;
|
|
2532
2974
|
}
|
|
@@ -2771,119 +3213,6 @@ function neutralize(text) {
|
|
|
2771
3213
|
return neutralizeControlChars(capLength(text, MAX_STRING_CHARS), { allowNewline: true });
|
|
2772
3214
|
}
|
|
2773
3215
|
//#endregion
|
|
2774
|
-
//#region src/catalog/search.ts
|
|
2775
|
-
/**
|
|
2776
|
-
* `search_tools` — the catalog projection's discovery tool (PRD R33, S5).
|
|
2777
|
-
*
|
|
2778
|
-
* Returns one page of the wrapped server's tools in catalog order — name,
|
|
2779
|
-
* a one-line purpose, the policy posture in plain words, and whether the
|
|
2780
|
-
* server marks the tool destructive — optionally filtered by a plain
|
|
2781
|
-
* substring. It is the lazy alternative to `discover_apis`' inline
|
|
2782
|
-
* overview: O(tools the model looks at), not O(tools the server has).
|
|
2783
|
-
*
|
|
2784
|
-
* Everything rendered comes from the S5-untrusted catalog, so names and
|
|
2785
|
-
* purposes are neutralized in the text rendering. `structuredContent.tools[].name`
|
|
2786
|
-
* stays verbatim: it is the key the model hands back to `describe_tool` and
|
|
2787
|
-
* `call_tool`, and JSON escaping already renders it inert there.
|
|
2788
|
-
*/
|
|
2789
|
-
/** Page size — a transport bound, independent of the R37 inline threshold. */
|
|
2790
|
-
const DEFAULT_SEARCH_PAGE_SIZE = 50;
|
|
2791
|
-
const MAX_PAGE_SIZE = 200;
|
|
2792
|
-
/** `query` is capped (R33): it is matched as a plain substring, never a regex. */
|
|
2793
|
-
const MAX_QUERY_CHARS = 200;
|
|
2794
|
-
/** Map a policy decision to its plain-words posture (R33). */
|
|
2795
|
-
function postureOf(decision) {
|
|
2796
|
-
switch (decision) {
|
|
2797
|
-
case "auto": return "runs immediately";
|
|
2798
|
-
case "ask": return "asks for approval";
|
|
2799
|
-
case "deny": return "blocked by policy";
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
/** Summarize one catalog entry (R33). */
|
|
2803
|
-
function summarizeTool(entry, policy) {
|
|
2804
|
-
return {
|
|
2805
|
-
name: entry.name,
|
|
2806
|
-
purpose: purposeSentence(entry.description) ?? "(no description)",
|
|
2807
|
-
posture: postureOf(decidePolicy(entry, policy)),
|
|
2808
|
-
destructive: entry.annotations?.destructiveHint === true
|
|
2809
|
-
};
|
|
2810
|
-
}
|
|
2811
|
-
/**
|
|
2812
|
-
* Run `search_tools`. Throws a `validate`-phase {@link ProjectionError} for a
|
|
2813
|
-
* malformed cursor, one issued for a different query, or one pointing past
|
|
2814
|
-
* the end of the results (R33).
|
|
2815
|
-
*/
|
|
2816
|
-
function searchTools(catalog, args, options = {}) {
|
|
2817
|
-
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, options.pageSize ?? 50));
|
|
2818
|
-
const query = (args.query ?? "").slice(0, 200);
|
|
2819
|
-
const offset = decodeCursor(args.cursor, query);
|
|
2820
|
-
const needle = query.toLowerCase();
|
|
2821
|
-
const matches = dedupeTools(catalog.tools).filter((entry) => needle.length === 0 || entry.name.toLowerCase().includes(needle) || (entry.description ?? "").toLowerCase().includes(needle));
|
|
2822
|
-
if (offset > 0 && offset >= matches.length) throw cursorError("cursor is past the end of the results");
|
|
2823
|
-
const slice = matches.slice(offset, offset + pageSize);
|
|
2824
|
-
const tools = slice.map((entry) => summarizeTool(entry, options.policy));
|
|
2825
|
-
const end = offset + slice.length;
|
|
2826
|
-
const page = {
|
|
2827
|
-
tools,
|
|
2828
|
-
total: matches.length,
|
|
2829
|
-
...end < matches.length ? { nextCursor: encodeCursor(query, end) } : {}
|
|
2830
|
-
};
|
|
2831
|
-
return {
|
|
2832
|
-
content: [{
|
|
2833
|
-
type: "text",
|
|
2834
|
-
text: renderPage(catalog, page, query, offset)
|
|
2835
|
-
}],
|
|
2836
|
-
structuredContent: { ...page }
|
|
2837
|
-
};
|
|
2838
|
-
}
|
|
2839
|
-
function renderPage(catalog, page, query, offset) {
|
|
2840
|
-
const lines = [];
|
|
2841
|
-
const scope = query.length > 0 ? ` matching ${JSON.stringify(neutralizeControlChars(query))}` : "";
|
|
2842
|
-
if (page.tools.length === 0) lines.push(`No tools${scope} on server ${inertIdentity$1(catalog.identity)}.`);
|
|
2843
|
-
else {
|
|
2844
|
-
lines.push(`Tools ${offset + 1}–${offset + page.tools.length} of ${page.total}${scope} (server ${inertIdentity$1(catalog.identity)}):`);
|
|
2845
|
-
for (const tool of page.tools) {
|
|
2846
|
-
const name = capLength(neutralizeControlChars(tool.name), 200);
|
|
2847
|
-
const flags = tool.destructive ? `${tool.posture}, destructive` : tool.posture;
|
|
2848
|
-
lines.push(`- ${name} — ${tool.purpose} [${flags}]`);
|
|
2849
|
-
}
|
|
2850
|
-
}
|
|
2851
|
-
if (page.nextCursor !== void 0) lines.push("", `More tools follow: call search_tools again with cursor ${JSON.stringify(page.nextCursor)}.`);
|
|
2852
|
-
lines.push("", "Call describe_tool with a name before calling a tool whose arguments you have not seen.");
|
|
2853
|
-
return lines.join("\n");
|
|
2854
|
-
}
|
|
2855
|
-
function encodeCursor(query, offset) {
|
|
2856
|
-
const payload = {
|
|
2857
|
-
q: query,
|
|
2858
|
-
o: offset
|
|
2859
|
-
};
|
|
2860
|
-
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
2861
|
-
}
|
|
2862
|
-
function cursorError(why) {
|
|
2863
|
-
return new ProjectionError("validate", `search_tools cursor is not usable: ${why}`, { hint: "Pass a cursor exactly as a previous search_tools result returned it, with the same query; or omit it to start from the first page." });
|
|
2864
|
-
}
|
|
2865
|
-
/**
|
|
2866
|
-
* Decode a cursor to its offset. Only the fields read (`q`, `o`) are
|
|
2867
|
-
* validated: a payload carrying extra keys is accepted, since the cursor is
|
|
2868
|
-
* opaque data and nothing else in it is consumed. The offset's upper bound
|
|
2869
|
-
* is checked by the caller once the match count is known.
|
|
2870
|
-
*/
|
|
2871
|
-
function decodeCursor(cursor, query) {
|
|
2872
|
-
if (cursor === void 0 || cursor.length === 0) return 0;
|
|
2873
|
-
if (cursor.length > 1024) throw cursorError("too long");
|
|
2874
|
-
let payload;
|
|
2875
|
-
try {
|
|
2876
|
-
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
2877
|
-
} catch {
|
|
2878
|
-
throw cursorError("malformed");
|
|
2879
|
-
}
|
|
2880
|
-
if (typeof payload !== "object" || payload === null || typeof payload.q !== "string" || typeof payload.o !== "number") throw cursorError("malformed");
|
|
2881
|
-
const { q, o } = payload;
|
|
2882
|
-
if (!Number.isInteger(o) || o < 0) throw cursorError("malformed");
|
|
2883
|
-
if (q !== query) throw cursorError("it was issued for a different query");
|
|
2884
|
-
return o;
|
|
2885
|
-
}
|
|
2886
|
-
//#endregion
|
|
2887
3216
|
//#region src/catalog/describe.ts
|
|
2888
3217
|
/**
|
|
2889
3218
|
* `describe_tool` — everything the model needs to call one tool (PRD R34).
|
|
@@ -2908,12 +3237,12 @@ const MAX_DESCRIPTION_CHARS = 2e3;
|
|
|
2908
3237
|
*/
|
|
2909
3238
|
function describeTool(catalog, args, options = {}) {
|
|
2910
3239
|
const entry = findTool(catalog, args.name);
|
|
2911
|
-
if (entry === void 0) throw
|
|
3240
|
+
if (entry === void 0) throw unknownToolError(catalog, args.name, "describe_tool");
|
|
2912
3241
|
const input = sanitizeSchema(entry.inputSchema);
|
|
2913
3242
|
const output = entry.outputSchema !== void 0 ? sanitizeSchema(entry.outputSchema) : void 0;
|
|
2914
3243
|
const annotations = sanitizeAnnotations(entry.annotations);
|
|
2915
3244
|
const description = entry.description !== void 0 && entry.description.trim().length > 0 ? neutralizeControlChars(capLength(entry.description.trim(), MAX_DESCRIPTION_CHARS), { allowNewline: true }) : void 0;
|
|
2916
|
-
const types = options.toolTypes?.get(entry.name)?.dts;
|
|
3245
|
+
const types = args.types === true ? options.toolTypes?.get(entry.name)?.dts : void 0;
|
|
2917
3246
|
const described = {
|
|
2918
3247
|
name: entry.name,
|
|
2919
3248
|
...description !== void 0 ? { description } : {},
|
|
@@ -3339,7 +3668,7 @@ function clampInlineThreshold(raw) {
|
|
|
3339
3668
|
function discoverApis(catalog, overview, options = {}) {
|
|
3340
3669
|
const threshold = clampInlineThreshold(options.inlineThreshold);
|
|
3341
3670
|
const toolCount = dedupeTools(catalog.tools).length;
|
|
3342
|
-
const toolsSection = options.catalog === true && toolCount > threshold ? `Tools: ${toolCount} on this server — too many to list here. Call search_tools to find tools by name or purpose and describe_tool for a tool's arguments
|
|
3671
|
+
const toolsSection = options.catalog === true && toolCount > threshold ? `Tools: ${toolCount} on this server — too many to list here. Call search_tools to find tools by name or purpose and describe_tool for a tool's arguments (types: true adds its TypeScript); the full definitions are in ${RESOURCE_URIS.toolTypes}.` : overview;
|
|
3343
3672
|
const inline = [
|
|
3344
3673
|
`Projection code mode over ${inertIdentity$1(catalog.identity)}.`,
|
|
3345
3674
|
"",
|
|
@@ -3573,39 +3902,67 @@ function toCatalogEntry(tool) {
|
|
|
3573
3902
|
}
|
|
3574
3903
|
/**
|
|
3575
3904
|
* Bounds on `tools/list` introspection. A wrapped server is untrusted (S5)
|
|
3576
|
-
* and could return an endless chain of `nextCursor` pages,
|
|
3577
|
-
* large enough to blow up the generated artifacts;
|
|
3578
|
-
* exhaust the process before any workflow runs.
|
|
3905
|
+
* and could return an endless chain of `nextCursor` pages, a cursor that
|
|
3906
|
+
* repeats, or a tool count large enough to blow up the generated artifacts;
|
|
3907
|
+
* any of these would hang or exhaust the process before any workflow runs.
|
|
3908
|
+
*
|
|
3909
|
+
* Projection walks the pages itself and enforces these bounds as each page
|
|
3910
|
+
* arrives (D12). It does not use the SDK's no-cursor `listTools()`, which
|
|
3911
|
+
* aggregates every page before returning and is bounded only by the
|
|
3912
|
+
* caller's `listMaxPages` client option (0 = unlimited).
|
|
3579
3913
|
*/
|
|
3580
3914
|
const MAX_TOOL_LIST_PAGES = 100;
|
|
3581
3915
|
const MAX_TOOLS = 2e3;
|
|
3916
|
+
function limitError(alias, detail) {
|
|
3917
|
+
return new ProjectionError("protocol", `wrapped server "${alias}" exceeded introspection limits (${detail})`, { hint: `Projection reads at most ${MAX_TOOL_LIST_PAGES} tools/list pages and ${MAX_TOOLS} tools, and rejects a repeated cursor. Wrap a server with a bounded tool surface whose tools/list pagination terminates.` });
|
|
3918
|
+
}
|
|
3919
|
+
async function fetchToolsPage(client, cursor) {
|
|
3920
|
+
if (cursor === void 0) return client.request({
|
|
3921
|
+
method: "tools/list",
|
|
3922
|
+
params: {}
|
|
3923
|
+
});
|
|
3924
|
+
return client.listTools({ cursor });
|
|
3925
|
+
}
|
|
3582
3926
|
async function listAllTools(client, alias) {
|
|
3927
|
+
if (client.getServerCapabilities()?.tools === void 0) return [];
|
|
3583
3928
|
const tools = [];
|
|
3929
|
+
const cursors = /* @__PURE__ */ new Set();
|
|
3584
3930
|
let cursor;
|
|
3585
3931
|
let pages = 0;
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
pages
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
}
|
|
3596
|
-
}
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
if (
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
}
|
|
3932
|
+
do {
|
|
3933
|
+
let page;
|
|
3934
|
+
try {
|
|
3935
|
+
page = await fetchToolsPage(client, cursor);
|
|
3936
|
+
} catch (err) {
|
|
3937
|
+
if (pages === 0 && err instanceof ProtocolError && err.code === ProtocolErrorCode.MethodNotFound) return [];
|
|
3938
|
+
throw new ProjectionError("protocol", `tools/list failed for wrapped server "${alias}": ${err instanceof Error ? err.message : String(err)}`, {
|
|
3939
|
+
hint: "Check that the wrapped server is running, connected, and supports the tools capability.",
|
|
3940
|
+
cause: err
|
|
3941
|
+
});
|
|
3942
|
+
}
|
|
3943
|
+
pages += 1;
|
|
3944
|
+
tools.push(...page.tools);
|
|
3945
|
+
if (tools.length > MAX_TOOLS) throw limitError(alias, `declares at least ${tools.length} tools, over the cap of ${MAX_TOOLS}`);
|
|
3946
|
+
cursor = page.nextCursor;
|
|
3947
|
+
if (cursor === void 0) break;
|
|
3948
|
+
if (cursors.has(cursor)) throw limitError(alias, `tools/list pagination loops: page ${pages} returned cursor ${JSON.stringify(cursor.slice(0, 64))} a second time`);
|
|
3949
|
+
if (pages >= MAX_TOOL_LIST_PAGES) throw limitError(alias, `tools/list still returns nextCursor after ${pages} pages, over the cap of ${MAX_TOOL_LIST_PAGES}`);
|
|
3950
|
+
cursors.add(cursor);
|
|
3951
|
+
} while (cursor !== void 0);
|
|
3606
3952
|
return tools;
|
|
3607
3953
|
}
|
|
3608
|
-
|
|
3954
|
+
/**
|
|
3955
|
+
* True when `source` is shaped like an `McpServer` (a `server` property plus
|
|
3956
|
+
* `connect`/`registerTool`, and no `listTools`, which a `Client` has) yet
|
|
3957
|
+
* failed the `instanceof McpServer` check: the caller's project resolved a
|
|
3958
|
+
* second copy of `@modelcontextprotocol/server`.
|
|
3959
|
+
*/
|
|
3960
|
+
function looksLikeForeignMcpServer(source) {
|
|
3961
|
+
if (typeof source !== "object" || source === null) return false;
|
|
3962
|
+
const candidate = source;
|
|
3963
|
+
return "server" in candidate && (typeof candidate["connect"] === "function" || typeof candidate["registerTool"] === "function") && typeof candidate["listTools"] !== "function";
|
|
3964
|
+
}
|
|
3965
|
+
async function introspectServer(alias, client) {
|
|
3609
3966
|
const tools = await listAllTools(client, alias);
|
|
3610
3967
|
const info = client.getServerVersion();
|
|
3611
3968
|
const catalog = {
|
|
@@ -3615,7 +3972,10 @@ async function buildServerCatalog(alias, client) {
|
|
|
3615
3972
|
};
|
|
3616
3973
|
const instructions = client.getInstructions();
|
|
3617
3974
|
if (instructions !== void 0) catalog.instructions = instructions;
|
|
3618
|
-
return
|
|
3975
|
+
return {
|
|
3976
|
+
catalog,
|
|
3977
|
+
definitions: new Map(tools.map((tool) => [tool.name, tool]))
|
|
3978
|
+
};
|
|
3619
3979
|
}
|
|
3620
3980
|
/** Extract a one-line error message from an `isError` tool result's content. */
|
|
3621
3981
|
function errorTextFromContent(content) {
|
|
@@ -3644,9 +4004,17 @@ function pickResultValue(result) {
|
|
|
3644
4004
|
var Introspector = class Introspector {
|
|
3645
4005
|
#connections;
|
|
3646
4006
|
#catalog;
|
|
3647
|
-
|
|
4007
|
+
#definitions;
|
|
4008
|
+
constructor(connections, introspected) {
|
|
3648
4009
|
this.#connections = connections;
|
|
3649
|
-
this.#catalog =
|
|
4010
|
+
this.#catalog = Introspector.#catalogOf(introspected);
|
|
4011
|
+
this.#definitions = Introspector.#definitionsOf(introspected);
|
|
4012
|
+
}
|
|
4013
|
+
static #catalogOf(introspected) {
|
|
4014
|
+
return { servers: new Map([...introspected].map(([alias, server]) => [alias, server.catalog])) };
|
|
4015
|
+
}
|
|
4016
|
+
static #definitionsOf(introspected) {
|
|
4017
|
+
return new Map([...introspected].map(([alias, server]) => [alias, server.definitions]));
|
|
3650
4018
|
}
|
|
3651
4019
|
static async create(sources) {
|
|
3652
4020
|
const aliases = Object.keys(sources);
|
|
@@ -3658,13 +4026,13 @@ var Introspector = class Introspector {
|
|
|
3658
4026
|
if (source === void 0) return;
|
|
3659
4027
|
const connection = await Introspector.#connect(alias, source);
|
|
3660
4028
|
connections.set(alias, connection);
|
|
3661
|
-
servers.set(alias, await
|
|
4029
|
+
servers.set(alias, await introspectServer(alias, connection.client));
|
|
3662
4030
|
}))).find((result) => result.status === "rejected");
|
|
3663
4031
|
if (failed !== void 0) {
|
|
3664
4032
|
await Introspector.#closeConnections(connections);
|
|
3665
4033
|
throw failed.reason;
|
|
3666
4034
|
}
|
|
3667
|
-
return new Introspector(connections,
|
|
4035
|
+
return new Introspector(connections, servers);
|
|
3668
4036
|
}
|
|
3669
4037
|
static async #connect(alias, source) {
|
|
3670
4038
|
if (source instanceof McpServer) {
|
|
@@ -3678,6 +4046,7 @@ var Introspector = class Introspector {
|
|
|
3678
4046
|
owned: true
|
|
3679
4047
|
};
|
|
3680
4048
|
}
|
|
4049
|
+
if (looksLikeForeignMcpServer(source)) throw new ProjectionError("protocol", `Wrapped server "${alias}" looks like an McpServer from a second copy of @modelcontextprotocol/server, not the one Projection uses.`, { hint: `Projection pins @modelcontextprotocol/server ${MCP_SDK_VERSION} exactly. Depend on that exact version so your package manager installs a single copy (check with \`npm ls @modelcontextprotocol/server\` or \`pnpm why @modelcontextprotocol/server\`).` });
|
|
3681
4050
|
return {
|
|
3682
4051
|
client: source,
|
|
3683
4052
|
owned: false
|
|
@@ -3692,8 +4061,9 @@ var Introspector = class Introspector {
|
|
|
3692
4061
|
}
|
|
3693
4062
|
/** Re-read `tools/list` from every wrapped server and rebuild the catalog. */
|
|
3694
4063
|
async refresh() {
|
|
3695
|
-
const rebuilt = await Promise.all([...this.#connections].map(async ([alias, connection]) => [alias, await
|
|
3696
|
-
this.#catalog =
|
|
4064
|
+
const rebuilt = new Map(await Promise.all([...this.#connections].map(async ([alias, connection]) => [alias, await introspectServer(alias, connection.client)])));
|
|
4065
|
+
this.#catalog = Introspector.#catalogOf(rebuilt);
|
|
4066
|
+
this.#definitions = Introspector.#definitionsOf(rebuilt);
|
|
3697
4067
|
return this.#catalog;
|
|
3698
4068
|
}
|
|
3699
4069
|
/**
|
|
@@ -3710,11 +4080,12 @@ var Introspector = class Introspector {
|
|
|
3710
4080
|
async callTool(alias, name, args) {
|
|
3711
4081
|
const connection = this.#connections.get(alias);
|
|
3712
4082
|
if (connection === void 0) throw new ProjectionError("protocol", `Unknown wrapped-server alias "${alias}".`, { hint: `Known aliases: ${[...this.#connections.keys()].join(", ")}. v1 wraps exactly one server under the "default" alias.` });
|
|
4083
|
+
const toolDefinition = this.#definitions.get(alias)?.get(name);
|
|
3713
4084
|
try {
|
|
3714
4085
|
const result = await connection.client.callTool({
|
|
3715
4086
|
name,
|
|
3716
4087
|
arguments: args
|
|
3717
|
-
});
|
|
4088
|
+
}, toolDefinition !== void 0 ? { toolDefinition } : void 0);
|
|
3718
4089
|
if (result.isError === true) return {
|
|
3719
4090
|
ok: false,
|
|
3720
4091
|
error: {
|
|
@@ -4000,18 +4371,35 @@ function pascalBase(toolName, taken) {
|
|
|
4000
4371
|
taken.add(base);
|
|
4001
4372
|
return base;
|
|
4002
4373
|
}
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4374
|
+
/**
|
|
4375
|
+
* One posture vocabulary for every surface (R33): the same words
|
|
4376
|
+
* `search_tools` and `describe_tool` use, so a model never sees
|
|
4377
|
+
* "[read-only]" on one surface and "runs immediately" on another.
|
|
4378
|
+
*/
|
|
4379
|
+
function postureNote(posture) {
|
|
4380
|
+
switch (posture) {
|
|
4381
|
+
case "runs immediately": return "Runs immediately: no user approval required.";
|
|
4382
|
+
case "asks for approval": return "Asks for approval: pauses for the user to approve this exact call before it runs.";
|
|
4383
|
+
case "blocked by policy": return "Blocked by policy: calls to this tool are refused.";
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
/**
|
|
4387
|
+
* The posture sentence plus, whatever the posture, the server's destructive
|
|
4388
|
+
* marker: an `auto` or `deny` override does not make a destructive tool any
|
|
4389
|
+
* less so, and `search_tools` / `describe_tool` show the flag on every
|
|
4390
|
+
* posture too.
|
|
4391
|
+
*/
|
|
4392
|
+
function approvalNote(tool, policy) {
|
|
4393
|
+
const note = postureNote(postureOf(decidePolicy(tool, policy)));
|
|
4394
|
+
return tool.annotations?.destructiveHint === true ? `${note} The server marks this tool destructive.` : note;
|
|
4007
4395
|
}
|
|
4008
|
-
function toolDoc(tool) {
|
|
4396
|
+
function toolDoc(tool, policy) {
|
|
4009
4397
|
const lines = [];
|
|
4010
4398
|
if (tool.description !== void 0 && tool.description.length > 0) lines.push(tool.description.trim(), "");
|
|
4011
|
-
lines.push(approvalNote(tool));
|
|
4399
|
+
lines.push(approvalNote(tool, policy));
|
|
4012
4400
|
return lines.join("\n");
|
|
4013
4401
|
}
|
|
4014
|
-
function generateDts(server) {
|
|
4402
|
+
function generateDts(server, policy) {
|
|
4015
4403
|
const taken = /* @__PURE__ */ new Set();
|
|
4016
4404
|
const sections = [];
|
|
4017
4405
|
const memberLines = [];
|
|
@@ -4039,11 +4427,11 @@ function generateDts(server) {
|
|
|
4039
4427
|
const optional = schemaRequiresArgs(tool.inputSchema) ? "" : "?";
|
|
4040
4428
|
const returnType = typeNames.output ?? "unknown";
|
|
4041
4429
|
const signature = `${propertyKey(tool.name)}: (args${optional}: ${typeNames.input}) => Promise<${returnType}>;`;
|
|
4042
|
-
memberLines.push(docComment(toolDoc(tool), " "), ` ${signature}`);
|
|
4430
|
+
memberLines.push(docComment(toolDoc(tool, policy), " "), ` ${signature}`);
|
|
4043
4431
|
toolTypes.set(tool.name, {
|
|
4044
4432
|
inputAlias: typeNames.input,
|
|
4045
4433
|
...typeNames.output !== void 0 ? { outputAlias: typeNames.output } : {},
|
|
4046
|
-
dts: [...aliasSections, `// Member of the \`tools\` surface:\n${docComment(toolDoc(tool), "")}\n${signature}`].join("\n\n")
|
|
4434
|
+
dts: [...aliasSections, `// Member of the \`tools\` surface:\n${docComment(toolDoc(tool, policy), "")}\n${signature}`].join("\n\n")
|
|
4047
4435
|
});
|
|
4048
4436
|
}
|
|
4049
4437
|
const surfaceDoc = docComment(`Namespaced tool surface of wrapped server ${commentSafe(server.identity)}.`, "");
|
|
@@ -4091,24 +4479,25 @@ function generateBridgeSource(server) {
|
|
|
4091
4479
|
""
|
|
4092
4480
|
].join("\n");
|
|
4093
4481
|
}
|
|
4094
|
-
function overviewFlags(tool) {
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
return "[approval]";
|
|
4482
|
+
function overviewFlags(tool, policy) {
|
|
4483
|
+
const posture = postureOf(decidePolicy(tool, policy));
|
|
4484
|
+
return tool.annotations?.destructiveHint === true ? `[${posture}, destructive]` : `[${posture}]`;
|
|
4098
4485
|
}
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4486
|
+
/**
|
|
4487
|
+
* The one-line purpose shown per tool: the first sentence of the
|
|
4488
|
+
* description, neutralized and capped — the same rule `search_tools` uses
|
|
4489
|
+
* (R33), so both projections describe a tool identically.
|
|
4490
|
+
*/
|
|
4491
|
+
function purposeLine(text) {
|
|
4492
|
+
return purposeSentence(text) ?? "(no description)";
|
|
4104
4493
|
}
|
|
4105
|
-
function generateOverview(server) {
|
|
4494
|
+
function generateOverview(server, policy) {
|
|
4106
4495
|
const lines = [`Tools (server: ${inertIdentity$1(server.identity)}):`];
|
|
4107
4496
|
if (server.tools.length === 0) lines.push(" (none)");
|
|
4108
4497
|
for (const tool of server.tools) {
|
|
4109
4498
|
const name = capLength(neutralizeControlChars(tool.name), 200);
|
|
4110
|
-
const description =
|
|
4111
|
-
lines.push(`- ${name} ${overviewFlags(tool)} — ${description}`);
|
|
4499
|
+
const description = purposeLine(tool.description);
|
|
4500
|
+
lines.push(`- ${name} ${overviewFlags(tool, policy)} — ${description}`);
|
|
4112
4501
|
}
|
|
4113
4502
|
return `${lines.join("\n")}\n`;
|
|
4114
4503
|
}
|
|
@@ -4128,13 +4517,13 @@ function resolveSingleServer(catalog) {
|
|
|
4128
4517
|
* exactly one server in v1, per R30's single-instance instantiation) or a
|
|
4129
4518
|
* {@link ServerCatalog} directly.
|
|
4130
4519
|
*/
|
|
4131
|
-
function generateToolTypes(catalog) {
|
|
4520
|
+
function generateToolTypes(catalog, options = {}) {
|
|
4132
4521
|
const server = dedupeToolNames(resolveSingleServer(catalog));
|
|
4133
|
-
const { dts, toolTypes } = generateDts(server);
|
|
4522
|
+
const { dts, toolTypes } = generateDts(server, options.policy);
|
|
4134
4523
|
return {
|
|
4135
4524
|
dts,
|
|
4136
4525
|
bridgeSource: generateBridgeSource(server),
|
|
4137
|
-
overview: generateOverview(server),
|
|
4526
|
+
overview: generateOverview(server, options.policy),
|
|
4138
4527
|
toolTypes
|
|
4139
4528
|
};
|
|
4140
4529
|
}
|
|
@@ -4168,9 +4557,9 @@ Author a module that default-exports a machine built with XState v5 setup(...).c
|
|
|
4168
4557
|
|
|
4169
4558
|
Call discover_apis first for the tool types, the full authoring contract, and worked examples.`;
|
|
4170
4559
|
const DISCOVER_APIS_DESCRIPTION = `Return this server's tool type definitions, the machine authoring contract, and worked examples. Call this before authoring a machine for execute_machine.`;
|
|
4171
|
-
const SEARCH_TOOLS_DESCRIPTION = `List this server's tools, one page at a time, optionally
|
|
4560
|
+
const SEARCH_TOOLS_DESCRIPTION = `List this server's tools, one page at a time, optionally matched against a query: the query is split into words, a tool matches if any word appears in its name, title or description, and matches are ranked (name hits weigh more), so a guessed name like create_issue or a phrase like "comment on a pull request" works. Each entry gives the tool's name, a one-line purpose, whether it runs immediately, asks the user for approval, or is blocked by policy, and whether the server marks it destructive. Call describe_tool before calling a tool whose arguments you have not seen.`;
|
|
4172
4561
|
const CALL_TOOL_DESCRIPTION = `Run one of this server's tools by name with a JSON object of arguments. Call describe_tool first to see the tool's arguments. Read-only tools run immediately. Any other tool first asks the user to approve this exact call; a result with status "declined" means the user said no — nothing ran, and you must not retry the same call unless the user asks for it. Status "denied_by_policy" means the server's policy blocks the tool. For several steps, branching on results, or a workflow worth saving, author a machine and use execute_machine instead.`;
|
|
4173
|
-
const DESCRIBE_TOOL_DESCRIPTION = `Return one tool's description, its input and output JSON Schemas, its approval posture
|
|
4562
|
+
const DESCRIBE_TOOL_DESCRIPTION = `Return one tool's description, its input and output JSON Schemas, and its approval posture. Call this before call_tool. Pass types: true to also get the tool's generated TypeScript when authoring a machine for execute_machine.`;
|
|
4174
4563
|
/**
|
|
4175
4564
|
* Wrap an MCP server (an in-process instance or a connected client) into a
|
|
4176
4565
|
* Projection facade.
|
|
@@ -4180,7 +4569,8 @@ async function wrapMcpServer(source, options) {
|
|
|
4180
4569
|
const introspector = await createIntrospector({ [DEFAULT_SERVER_ALIAS]: source });
|
|
4181
4570
|
const catalog = introspector.catalog.servers.get(DEFAULT_SERVER_ALIAS);
|
|
4182
4571
|
if (catalog === void 0) throw new ProjectionError("protocol", "introspection produced no catalog for the wrapped server", { hint: "Check that the wrapped server initialized successfully." });
|
|
4183
|
-
const
|
|
4572
|
+
const policy = options.policy !== void 0 ? Object.freeze({ ...options.policy }) : void 0;
|
|
4573
|
+
const { dts, bridgeSource, overview, toolTypes } = generateToolTypes(catalog, { policy });
|
|
4184
4574
|
return new Facade({
|
|
4185
4575
|
introspector,
|
|
4186
4576
|
catalog,
|
|
@@ -4191,6 +4581,7 @@ async function wrapMcpServer(source, options) {
|
|
|
4191
4581
|
resources: buildResources(catalog, dts),
|
|
4192
4582
|
instructions: buildInstructions(catalog, options.codeModePreamble, projections),
|
|
4193
4583
|
options,
|
|
4584
|
+
...policy !== void 0 ? { policy } : {},
|
|
4194
4585
|
projections
|
|
4195
4586
|
});
|
|
4196
4587
|
}
|
|
@@ -4239,13 +4630,13 @@ var Facade = class {
|
|
|
4239
4630
|
* `exactOptionalPropertyTypes`: absent options stay absent.
|
|
4240
4631
|
*/
|
|
4241
4632
|
gateDeps(era, clientCanElicit) {
|
|
4242
|
-
const { options } = this.deps;
|
|
4633
|
+
const { options, policy } = this.deps;
|
|
4243
4634
|
return {
|
|
4244
4635
|
catalog: this.deps.catalog,
|
|
4245
4636
|
executor: this.deps.introspector,
|
|
4246
4637
|
blob: options.blob,
|
|
4247
4638
|
...options.caps !== void 0 ? { caps: options.caps } : {},
|
|
4248
|
-
...
|
|
4639
|
+
...policy !== void 0 ? { policy } : {},
|
|
4249
4640
|
...options.redact !== void 0 ? { redact: options.redact } : {},
|
|
4250
4641
|
...era !== void 0 ? { protocolEra: era } : {},
|
|
4251
4642
|
...options.compat?.legacyElicitation === true ? { legacyElicitation: true } : {},
|
|
@@ -4279,7 +4670,7 @@ var Facade = class {
|
|
|
4279
4670
|
}
|
|
4280
4671
|
/** `exactOptionalPropertyTypes`: an absent policy must stay absent. */
|
|
4281
4672
|
policyOption() {
|
|
4282
|
-
const { policy } = this.deps
|
|
4673
|
+
const { policy } = this.deps;
|
|
4283
4674
|
return policy !== void 0 ? { policy } : {};
|
|
4284
4675
|
}
|
|
4285
4676
|
/**
|
|
@@ -4319,7 +4710,7 @@ var Facade = class {
|
|
|
4319
4710
|
title: "Search the tool catalog",
|
|
4320
4711
|
description: SEARCH_TOOLS_DESCRIPTION,
|
|
4321
4712
|
inputSchema: z.object({
|
|
4322
|
-
query: z.string().optional().describe("
|
|
4713
|
+
query: z.string().optional().describe("Words to match against tool names, titles and descriptions; any word matches, name hits weigh more."),
|
|
4323
4714
|
cursor: z.string().optional().describe("nextCursor from a previous page.")
|
|
4324
4715
|
}),
|
|
4325
4716
|
annotations: { readOnlyHint: true }
|
|
@@ -4330,9 +4721,15 @@ var Facade = class {
|
|
|
4330
4721
|
server.registerTool("describe_tool", {
|
|
4331
4722
|
title: "Describe one tool",
|
|
4332
4723
|
description: DESCRIBE_TOOL_DESCRIPTION,
|
|
4333
|
-
inputSchema: z.object({
|
|
4724
|
+
inputSchema: z.object({
|
|
4725
|
+
name: z.string().describe("Tool name as listed by search_tools."),
|
|
4726
|
+
types: z.boolean().optional().describe("Also return the generated TypeScript for this tool (for authoring a machine).")
|
|
4727
|
+
}),
|
|
4334
4728
|
annotations: { readOnlyHint: true }
|
|
4335
|
-
}, (args) => this.describeTool({
|
|
4729
|
+
}, (args) => this.describeTool({
|
|
4730
|
+
name: args.name,
|
|
4731
|
+
...args.types !== void 0 ? { types: args.types } : {}
|
|
4732
|
+
}));
|
|
4336
4733
|
server.registerTool("call_tool", {
|
|
4337
4734
|
title: "Call one tool",
|
|
4338
4735
|
description: CALL_TOOL_DESCRIPTION,
|