@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/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: 1048576,
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.7.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.7.0") throw verificationError(`library version mismatch: blob was sealed by @astral-mcp/projection ${JSON.stringify(payload["libv"])}, this instance runs ${LIB_VERSION}`, HINT_VERSION);
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 quickJsPromise;
649
+ let variantPromise;
650
+ let wasmModulePromise;
642
651
  let tsSupportPromise;
643
652
  const QUICKJS_VARIANT = "@jitl/quickjs-ng-wasmfile-release-sync";
644
- function getQuickJs() {
645
- quickJsPromise ??= (async () => {
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
- quickJsPromise = void 0;
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 loadQuickJs(variant.default);
673
+ return variant.default;
657
674
  })();
658
- return quickJsPromise;
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 runtime = await getQuickJs();
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.catch(() => {});
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 run = queue.then(task, task);
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 MAX_PURPOSE_CHARS = 200;
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 title = destructive ? `⚠ Approve: ${friendly}` : `Approve: ${friendly}`;
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} · requested directly by the model` : `Tool ${safeName} · requested by step ${JSON.stringify(safeState)}`);
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 unknownTool(args.name);
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 unknownTool(recorded.name);
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": return value.truncated === true ? `${JSON.stringify(value, null, 2)}\n\nThe call ran, but its result is ${value.outputBytes} bytes, over this server's result limit; output is a preview of 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.` : JSON.stringify(value, null, 2);
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 new ProjectionError("validate", `Unknown tool ${JSON.stringify(capLength(neutralizeControlChars(args.name), 200))}: the wrapped server does not declare it`, { hint: "Call search_tools to list the tools this server declares, then describe_tool with one of those names exactly." });
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 and types; the full definitions are in ${RESOURCE_URIS.toolTypes}.` : overview;
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, or a tool count
3577
- * large enough to blow up the generated artifacts; either would hang or
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
- try {
3587
- do {
3588
- const page = await client.listTools(cursor !== void 0 ? { cursor } : void 0);
3589
- tools.push(...page.tools);
3590
- cursor = page.nextCursor;
3591
- pages += 1;
3592
- if (pages >= MAX_TOOL_LIST_PAGES || tools.length >= MAX_TOOLS) {
3593
- if (cursor !== void 0) throw new ProjectionError("protocol", `wrapped server "${alias}" exceeded introspection limits (${pages} pages, ${tools.length} tools)`, { hint: `Projection reads at most ${MAX_TOOL_LIST_PAGES} tools/list pages and ${MAX_TOOLS} tools. Wrap a server with a bounded tool surface.` });
3594
- break;
3595
- }
3596
- } while (cursor !== void 0);
3597
- } catch (err) {
3598
- if (err instanceof ProjectionError) throw err;
3599
- if (err instanceof ProtocolError && err.code === ProtocolErrorCode.MethodNotFound) return [];
3600
- if (err instanceof Error && /does not support tools/i.test(err.message)) return [];
3601
- throw new ProjectionError("protocol", `tools/list failed for wrapped server "${alias}": ${err instanceof Error ? err.message : String(err)}`, {
3602
- hint: "Check that the wrapped server is running, connected, and supports the tools capability.",
3603
- cause: err
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
- async function buildServerCatalog(alias, client) {
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 catalog;
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
- constructor(connections, catalog) {
4007
+ #definitions;
4008
+ constructor(connections, introspected) {
3648
4009
  this.#connections = connections;
3649
- this.#catalog = 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 buildServerCatalog(alias, connection.client));
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, { servers });
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 buildServerCatalog(alias, connection.client)]));
3696
- this.#catalog = { servers: new Map(rebuilt) };
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
- function approvalNote(tool) {
4004
- if (tool.annotations?.readOnlyHint === true) return "Read-only: executes immediately, no user approval required.";
4005
- if (tool.annotations?.destructiveHint === true) return "DESTRUCTIVE write: pauses the machine for user approval before executing.";
4006
- return "Write (or unannotated): pauses the machine for user approval before executing.";
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
- if (tool.annotations?.readOnlyHint === true) return "[read-only]";
4096
- if (tool.annotations?.destructiveHint === true) return "[approval, destructive]";
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
- function firstLine(text) {
4100
- if (text === void 0) return "(no description)";
4101
- const line = (text.split("\n")[0] ?? "").trim();
4102
- if (line.length === 0) return "(no description)";
4103
- return line.length > 100 ? `${line.slice(0, 97)}...` : line;
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 = neutralizeControlChars(firstLine(tool.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 filtered by a plain substring on name and description. Each entry gives the tool's name, a one-line purpose, whether it runs immediately or asks the user for approval, and whether the server marks it destructive. Call describe_tool before calling a tool whose arguments you have not seen.`;
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, and its generated TypeScript types. Call this before call_tool, or when authoring a machine that uses the tool.`;
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 { dts, bridgeSource, overview, toolTypes } = generateToolTypes(catalog);
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
- ...options.policy !== void 0 ? { policy: options.policy } : {},
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.options;
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("Plain substring to match on name and description."),
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({ name: z.string().describe("Tool name as listed by search_tools.") }),
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({ name: args.name }));
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,