@sema-agent/core 5.52.0 → 5.54.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/agents/subagent.d.ts +4 -0
  3. package/dist/agents/subagent.js +1 -1
  4. package/dist/brain/anthropic.js +17 -4
  5. package/dist/core/a2a.js +12 -1
  6. package/dist/core/cache-break-detector.js +9 -3
  7. package/dist/core/mcp.d.ts +168 -5
  8. package/dist/core/mcp.js +200 -21
  9. package/dist/core/permission-rule-model.d.ts +140 -21
  10. package/dist/core/permission-rule-model.js +76 -17
  11. package/dist/core/permission-rule-org.d.ts +4 -3
  12. package/dist/core/permission-rule-org.js +12 -3
  13. package/dist/core/protocol-naming.d.ts +25 -2
  14. package/dist/core/protocol-naming.js +11 -0
  15. package/dist/core/runner/prepare-safety-scan.js +7 -0
  16. package/dist/core/runner/prepare-task.js +24 -3
  17. package/dist/core/runner/runtask.js +4 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/orchestration/workflow-types.d.ts +16 -6
  21. package/dist/orchestration/workflow-types.js +10 -4
  22. package/dist/orchestration/workflow.js +32 -6
  23. package/dist/stores/file/background-agent-store.js +1 -0
  24. package/dist/stores/file/checkpoint-store.d.ts +6 -2
  25. package/dist/stores/file/checkpoint-store.js +1 -0
  26. package/dist/stores/file/fs-atomic.d.ts +151 -10
  27. package/dist/stores/file/fs-atomic.js +208 -32
  28. package/dist/stores/file/index.d.ts +26 -3
  29. package/dist/stores/file/index.js +25 -2
  30. package/dist/stores/file/shared-ledger.d.ts +40 -5
  31. package/dist/stores/file/shared-ledger.js +24 -8
  32. package/dist/stores/file/workflow-run-store.d.ts +8 -1
  33. package/dist/stores/file/workflow-run-store.js +1 -0
  34. package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
  35. package/dist/tools/fs/bash-readonly-classifier.js +58 -47
  36. package/package.json +1 -1
  37. package/test/export-surface.snapshot.json +3 -1
package/dist/core/mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { MCP_NAMESPACE } from "./protocol-table.js";
2
- import { mintNamespacePrefix, mintNamespacedToolName, normalizeNameSegment } from "./protocol-naming.js";
2
+ import { findNamespacePrefixCollision, mintNamespacePrefix, mintNamespacedToolName, normalizeNameSegment } from "./protocol-naming.js";
3
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
4
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
5
  import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -7,6 +7,7 @@ import { lstat, mkdir, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { CallToolResultSchema, ElicitRequestSchema, ErrorCode, ListResourcesResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
10
+ import { DEFAULT_REQUEST_TIMEOUT_MSEC } from "@modelcontextprotocol/sdk/shared/protocol.js";
10
11
  import { MCP_IMAGE_MAX_BASE64, sharpImageResizer } from "./image-downsample.js";
11
12
  import { sliceHeadSafe, sliceTailSafe } from "./surrogate-safe-slice.js";
12
13
  import { truncateError } from "./tool-errors.js";
@@ -663,6 +664,17 @@ export function mcpToolSchemaProblem(schema) {
663
664
  return undefined;
664
665
  }
665
666
  export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure, mcpRevocations) {
667
+ {
668
+ const collision = findNamespacePrefixCollision(MCP_NAMESPACE, specs.map((s) => s.name));
669
+ if (collision) {
670
+ const [a, b] = collision.peers;
671
+ const e = new Error(a === b
672
+ ? `MCP server "${inlineUntrusted(a, 120)}" is declared twice — each server needs its own name (both mount under "${collision.prefix}", so their tools would shadow each other and a refresh of one would unmount the other's).`
673
+ : `MCP server names "${inlineUntrusted(a, 120)}" and "${inlineUntrusted(b, 120)}" both mount under "${collision.prefix}" — the namespaced tool name keeps only [a-zA-Z0-9_-], so they are the same domain to this engine (their tools would shadow each other and a refresh of one would unmount the other's). Rename one.`);
674
+ e.code = "config.mcp_server_name_collision";
675
+ throw e;
676
+ }
677
+ }
666
678
  let revocationProbeFailed = false;
667
679
  const isServerRevoked = (serverName) => {
668
680
  if (mcpRevocations === undefined)
@@ -708,7 +720,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
708
720
  if (r.status === "fulfilled") {
709
721
  const s = r.value;
710
722
  clients.push(s.client);
711
- serverHandles.push({ name: spec.name, spec, client: s.client, health: s.health, toolNames: s.tools.map((t) => t.name) });
723
+ serverHandles.push({ name: spec.name, spec, client: s.client, health: s.health, tools: s.tools, listedRaw: s.listedTools });
712
724
  tools.push(...s.tools);
713
725
  toolAxes.push(...s.axes);
714
726
  if (s.instructions) {
@@ -729,6 +741,8 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
729
741
  resourceServers.push(s.resourceServer);
730
742
  for (const d of s.dropped)
731
743
  droppedTools.push({ server: inlineUntrusted(spec.name, 160), ...d });
744
+ if (s.listingIncomplete)
745
+ warnings.push(listingIncompleteWarning(spec.name, s.listingIncomplete));
732
746
  statuses.push(s.status);
733
747
  }
734
748
  else {
@@ -770,12 +784,17 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
770
784
  }
771
785
  try {
772
786
  const listed = await listToolsLenient(h.client);
773
- cacheMcpToolMetadata(h.client, listed.tools);
774
- const { serverTools, serverAxes, dropped } = intakeListedTools(listed, h.spec, h.client, h.health, imageResizer, mcpDisclosure, isServerRevoked);
787
+ const advertised = new Set(listed.tools.map((t) => t.name));
788
+ const retainedRaw = listed.incomplete !== undefined ? h.listedRaw.filter((t) => !advertised.has(t.name)) : [];
789
+ const mergedRaw = retainedRaw.length > 0 ? [...listed.tools, ...retainedRaw] : listed.tools;
790
+ cacheMcpToolMetadata(h.client, retainedRaw.length > 0 ? [...retainedRaw, ...listed.tools] : listed.tools);
791
+ const { serverTools, serverAxes, dropped } = intakeListedTools({ tools: mergedRaw }, h.spec, h.client, h.health, imageResizer, mcpDisclosure, isServerRevoked);
792
+ const priorNames = h.tools.map((t) => t.name);
775
793
  const newNames = serverTools.map((t) => t.name);
776
- const added = newNames.filter((n) => !h.toolNames.includes(n));
777
- const removed = h.toolNames.filter((n) => !newNames.includes(n));
778
- h.toolNames = newNames;
794
+ const added = newNames.filter((n) => !priorNames.includes(n));
795
+ const removed = priorNames.filter((n) => !newNames.includes(n));
796
+ h.tools = serverTools;
797
+ h.listedRaw = mergedRaw;
779
798
  results.push({
780
799
  server: h.name,
781
800
  prefix: prefixOf(h.name),
@@ -786,6 +805,9 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
786
805
  tools: serverTools,
787
806
  axes: serverAxes,
788
807
  dropped: dropped.map((d) => ({ server: inlineUntrusted(h.name, 160), ...d })),
808
+ ...(listed.incomplete !== undefined
809
+ ? { listingIncomplete: listed.incomplete, error: `tool listing incomplete — ${listingIncompleteNote(listed.incomplete)}` }
810
+ : {}),
789
811
  });
790
812
  }
791
813
  catch (err) {
@@ -793,7 +815,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
793
815
  server: h.name,
794
816
  prefix: prefixOf(h.name),
795
817
  status: "failed",
796
- toolCount: h.toolNames.length,
818
+ toolCount: h.tools.length,
797
819
  added: [],
798
820
  removed: [],
799
821
  error: inlineUntrusted(namedMcpFailureText(err), 240),
@@ -864,6 +886,110 @@ function resourceLine(r) {
864
886
  function serverNames(servers) {
865
887
  return servers.map((r) => r.server).join(", ") || "(none)";
866
888
  }
889
+ const MAX_MCP_LIST_PAGES = 20;
890
+ const MIN_MCP_PAGE_BUDGET_MS = 250;
891
+ function continuationPageFloorMs(budgetMs) {
892
+ return Math.max(1, Math.min(MIN_MCP_PAGE_BUDGET_MS, Math.floor(budgetMs / 10)));
893
+ }
894
+ export function listEntryFingerprint(entry) {
895
+ try {
896
+ const json = JSON.stringify(entry);
897
+ return typeof json === "string" ? json : undefined;
898
+ }
899
+ catch {
900
+ return undefined;
901
+ }
902
+ }
903
+ const LIST_ENTRY_FINGERPRINT_MAX_CHARS = 8 * 1024;
904
+ const LOOP_REWIND_FINGERPRINT_MAX_CHARS = 1024 * 1024;
905
+ export async function walkMcpListPages(fetchPage, opts) {
906
+ const items = [];
907
+ const used = new Set();
908
+ const deadline = Date.now() + opts.budgetMs;
909
+ let cursor;
910
+ let pages = 0;
911
+ for (;;) {
912
+ const remainingMs = pages === 0 ? opts.budgetMs : deadline - Date.now();
913
+ if (pages > 0 && remainingMs < continuationPageFloorMs(opts.budgetMs)) {
914
+ return { items, incomplete: { reason: "budget_exhausted", pages, budgetMs: opts.budgetMs } };
915
+ }
916
+ let page;
917
+ try {
918
+ page = await fetchPage(cursor, remainingMs);
919
+ }
920
+ catch (err) {
921
+ if (pages === 0 || opts.signal?.aborted)
922
+ throw err;
923
+ return { items, incomplete: { reason: "page_error", pages, error: err instanceof Error ? err.message : String(err) } };
924
+ }
925
+ opts.onPage?.();
926
+ const pageStart = items.length;
927
+ items.push(...page.items);
928
+ pages++;
929
+ if (page.cursorInvalid === true)
930
+ return { items, incomplete: { reason: "cursor_invalid", pages } };
931
+ const next = page.nextCursor;
932
+ if (next === undefined)
933
+ return { items };
934
+ if (used.has(next)) {
935
+ const fresh = [];
936
+ const fingerprintOf = opts.fingerprintOf;
937
+ if (fingerprintOf !== undefined) {
938
+ const before = new Set();
939
+ let keying = LOOP_REWIND_FINGERPRINT_MAX_CHARS;
940
+ const keyOf = (item) => {
941
+ if (keying <= 0)
942
+ return undefined;
943
+ const fp = fingerprintOf(item);
944
+ if (fp === undefined) {
945
+ keying -= LIST_ENTRY_FINGERPRINT_MAX_CHARS;
946
+ return undefined;
947
+ }
948
+ keying -= fp.length;
949
+ return fp.length <= LIST_ENTRY_FINGERPRINT_MAX_CHARS ? fp : undefined;
950
+ };
951
+ for (let i = 0; i < pageStart; i++) {
952
+ const fp = keyOf(items[i]);
953
+ if (fp !== undefined)
954
+ before.add(fp);
955
+ }
956
+ for (const item of page.items) {
957
+ const fp = keyOf(item);
958
+ if (fp !== undefined && before.has(fp))
959
+ continue;
960
+ fresh.push(item);
961
+ }
962
+ }
963
+ items.length = pageStart;
964
+ items.push(...fresh);
965
+ return { items, incomplete: { reason: "cursor_loop", pages } };
966
+ }
967
+ if (pages >= MAX_MCP_LIST_PAGES)
968
+ return { items, incomplete: { reason: "page_cap", pages } };
969
+ used.add(next);
970
+ cursor = next;
971
+ }
972
+ }
973
+ function idleWatchdogTripText(idleMs) {
974
+ return `received no response for ${idleMs}ms (idle watchdog; set MCP_IDLE_TIMEOUT_STDIO / MCP_IDLE_TIMEOUT_HTTP to change this bound)`;
975
+ }
976
+ function listingIncompleteNote(flag) {
977
+ const pages = `${flag.pages} page${flag.pages === 1 ? "" : "s"}`;
978
+ if (flag.reason === "page_cap") {
979
+ return `[listing truncated: the ${MAX_MCP_LIST_PAGES}-page pagination limit was reached — more entries may exist beyond this listing]`;
980
+ }
981
+ if (flag.reason === "cursor_loop") {
982
+ return `[listing truncated: after ${pages} the server handed back a pagination cursor it had already used, so the walk stopped — more entries may exist beyond this listing]`;
983
+ }
984
+ if (flag.reason === "cursor_invalid") {
985
+ return `[listing truncated: after ${pages} the server sent a pagination cursor that is not a string, so the walk could not continue — more entries may exist beyond this listing]`;
986
+ }
987
+ if (flag.reason === "budget_exhausted") {
988
+ const budget = flag.budgetMs !== undefined ? `${flag.budgetMs}ms ` : "";
989
+ return `[listing truncated: after ${pages} the ${budget}time budget for this listing was too low to give the next page a usable request, so the client stopped without sending one — this is a client-side bound, not a server failure; more entries may exist beyond this listing]`;
990
+ }
991
+ return `[listing incomplete: the server failed mid-pagination after ${pages} (${inlineUntrusted(flag.error ?? "unknown error", 240)}) — only the pages retrieved before the failure are shown]`;
992
+ }
867
993
  async function readDirViaExtension(rs, uri, signal, timeoutMs, watchdog) {
868
994
  const resources = [];
869
995
  let cursor;
@@ -907,10 +1033,13 @@ function renderDirChildren(server, uri, children, flags) {
907
1033
  if (flags?.cursorInvalid) {
908
1034
  notes.push(`[listing incomplete: the server rejected the pagination cursor (invalid or expired) — only the pages retrieved before the rejection are shown; more entries may exist beyond this listing]`);
909
1035
  }
1036
+ if (flags?.listing)
1037
+ notes.push(listingIncompleteNote(flags.listing));
910
1038
  const detailFlags = {
911
1039
  ...(flags?.truncated ? { truncated: true } : {}),
912
1040
  ...(flags?.incomplete !== undefined ? { incomplete: true, error: flags.incomplete } : {}),
913
1041
  ...(flags?.cursorInvalid ? { incomplete: true, cursorInvalid: true } : {}),
1042
+ ...(flags?.listing ? { incomplete: true, listing: flags.listing } : {}),
914
1043
  };
915
1044
  const listing = children.length === 0
916
1045
  ? `(Empty directory: ${inlineUntrusted(uri)})`
@@ -948,6 +1077,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
948
1077
  const sections = [];
949
1078
  const all = [];
950
1079
  const errors = [];
1080
+ const incomplete = [];
951
1081
  for (const rs of targets) {
952
1082
  if (isServerRevoked(rs.server)) {
953
1083
  errors.push({ server: rs.server, error: "server revoked by the operator mid-session (request not sent)" });
@@ -962,16 +1092,30 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
962
1092
  const idleMs = mcpIdleTimeoutMs(rs.transportKind);
963
1093
  const watchdog = armMcpIdleWatchdog(rs.health, idleMs, signal);
964
1094
  try {
965
- const res = await rs.client.listResources(undefined, { signal: watchdog.combinedSignal, timeout: mcpToolTimeoutMs() });
966
- all.push(...res.resources.map((r) => ({ server: rs.server, ...r })));
967
- const lines = res.resources.map((r) => resourceLine(r));
968
- sections.push(`[${rs.server}]\n${lines.length ? lines.join("\n") : "(no resources)"}`);
1095
+ const walk = await walkMcpListPages(async (cursor, remainingMs) => {
1096
+ const page = await rs.client
1097
+ .listResources(cursor === undefined ? undefined : { cursor }, { signal: watchdog.combinedSignal, timeout: remainingMs })
1098
+ .catch((err) => {
1099
+ throw watchdog.idleSignal.reason === IDLE_WATCHDOG_ABORT_REASON ? new Error(idleWatchdogTripText(idleMs)) : err;
1100
+ });
1101
+ return { items: page.resources, ...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}) };
1102
+ }, { budgetMs: mcpToolTimeoutMs(), onPage: () => watchdog.rearm(), fingerprintOf: listEntryFingerprint, ...(signal !== undefined ? { signal } : {}) });
1103
+ all.push(...walk.items.map((r) => ({ server: rs.server, ...r })));
1104
+ const lines = walk.items.map((r) => resourceLine(r));
1105
+ const body = lines.length ? lines.join("\n") : "(no resources)";
1106
+ if (walk.incomplete) {
1107
+ incomplete.push({ server: rs.server, ...walk.incomplete });
1108
+ sections.push(`[${rs.server}]\n${body}\n${listingIncompleteNote(walk.incomplete)}`);
1109
+ }
1110
+ else {
1111
+ sections.push(`[${rs.server}]\n${body}`);
1112
+ }
969
1113
  }
970
1114
  catch (err) {
971
1115
  if (signal?.aborted)
972
1116
  throw err;
973
1117
  const msg = watchdog.idleSignal.reason === IDLE_WATCHDOG_ABORT_REASON
974
- ? `received no response for ${idleMs}ms (idle watchdog; set MCP_IDLE_TIMEOUT_STDIO / MCP_IDLE_TIMEOUT_HTTP to change this bound)`
1118
+ ? idleWatchdogTripText(idleMs)
975
1119
  : err instanceof Error
976
1120
  ? err.message
977
1121
  : String(err);
@@ -985,7 +1129,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
985
1129
  const body = sections.join("\n\n");
986
1130
  return {
987
1131
  content: [{ type: "text", text: sections.length ? delimitUntrusted("mcp resources", body) : "(no MCP resources)" }],
988
- details: { resources: all, ...(errors.length ? { errors } : {}) },
1132
+ details: { resources: all, ...(errors.length ? { errors } : {}), ...(incomplete.length ? { incomplete } : {}) },
989
1133
  terminate: false,
990
1134
  };
991
1135
  },
@@ -1114,12 +1258,15 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1114
1258
  return renderDirChildren(server, uri, r.resources, { truncated: r.truncated, incomplete: r.incomplete, cursorInvalid: r.cursorInvalid });
1115
1259
  watchdog.rearm();
1116
1260
  }
1117
- const res = await rs.client
1118
- .listResources(undefined, { signal: watchdog.combinedSignal, timeout: timeoutMs })
1119
- .catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
1261
+ const walk = await walkMcpListPages(async (cursor, remainingMs) => {
1262
+ const page = await rs.client
1263
+ .listResources(cursor === undefined ? undefined : { cursor }, { signal: watchdog.combinedSignal, timeout: remainingMs })
1264
+ .catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs: remainingMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
1265
+ return { items: page.resources, ...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}) };
1266
+ }, { budgetMs: timeoutMs, onPage: () => watchdog.rearm(), fingerprintOf: listEntryFingerprint, ...(signal !== undefined ? { signal } : {}) });
1120
1267
  const dirPrefix = uri.endsWith("/") ? uri : `${uri}/`;
1121
- const children = res.resources.filter((r) => typeof r.uri === "string" && r.uri !== uri && r.uri.startsWith(dirPrefix));
1122
- return renderDirChildren(server, uri, children);
1268
+ const children = walk.items.filter((r) => typeof r.uri === "string" && r.uri !== uri && r.uri.startsWith(dirPrefix));
1269
+ return renderDirChildren(server, uri, children, walk.incomplete ? { listing: walk.incomplete } : undefined);
1123
1270
  }
1124
1271
  finally {
1125
1272
  watchdog.dispose();
@@ -1166,7 +1313,8 @@ const LenientListToolsResultSchema = {
1166
1313
  });
1167
1314
  }
1168
1315
  const nextCursor = data.nextCursor;
1169
- return { success: true, data: { tools, ...(typeof nextCursor === "string" ? { nextCursor } : {}) } };
1316
+ const cursorInvalid = nextCursor !== undefined && nextCursor !== null && typeof nextCursor !== "string";
1317
+ return { success: true, data: { tools, ...(typeof nextCursor === "string" ? { nextCursor } : {}), ...(cursorInvalid ? { cursorInvalid: true } : {}) } };
1170
1318
  },
1171
1319
  };
1172
1320
  export function parseCallToolResultLenient(data) {
@@ -1181,7 +1329,19 @@ export function parseCallToolResultLenient(data) {
1181
1329
  }
1182
1330
  const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient };
1183
1331
  async function listToolsLenient(client, options) {
1184
- return client.request({ method: "tools/list", params: {} }, LenientListToolsResultSchema, options);
1332
+ const walk = await walkMcpListPages(async (cursor, remainingMs) => {
1333
+ const page = await client.request({ method: "tools/list", params: cursor === undefined ? {} : { cursor } }, LenientListToolsResultSchema, { ...options, timeout: remainingMs });
1334
+ return {
1335
+ items: page.tools,
1336
+ ...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}),
1337
+ ...(page.cursorInvalid === true ? { cursorInvalid: true } : {}),
1338
+ };
1339
+ }, {
1340
+ budgetMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC,
1341
+ fingerprintOf: listEntryFingerprint,
1342
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
1343
+ });
1344
+ return { tools: walk.items, ...(walk.incomplete !== undefined ? { incomplete: walk.incomplete } : {}) };
1185
1345
  }
1186
1346
  async function connectServer(spec, principal, onElicit, imageResizer, reminderDisclosure, isServerRevoked) {
1187
1347
  const elicitOn = spec.elicitation === true && onElicit !== undefined;
@@ -1243,7 +1403,9 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1243
1403
  tools: serverTools,
1244
1404
  axes: serverAxes,
1245
1405
  dropped,
1406
+ listedTools: listed.tools,
1246
1407
  ...(instructions ? { instructions } : {}),
1408
+ ...(listed.incomplete !== undefined ? { listingIncomplete: listed.incomplete } : {}),
1247
1409
  ...(resourceInfo && (resourceInfo.listAllowed || resourceInfo.readAllowed) ? { resourceServer: { server: spec.name, client, health, transportKind: idleKindOf(spec), ...resourceInfo } } : {}),
1248
1410
  };
1249
1411
  }
@@ -1256,6 +1418,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1256
1418
  const serverTools = [];
1257
1419
  const serverAxes = [];
1258
1420
  const dropped = [];
1421
+ const mintedNames = new Map();
1259
1422
  for (const t of listed.tools) {
1260
1423
  if (spec.allowTools && !spec.allowTools.includes(t.name)) {
1261
1424
  continue;
@@ -1278,6 +1441,17 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1278
1441
  }
1279
1442
  const remoteName = t.name;
1280
1443
  const namespacedName = mintNamespacedToolName(MCP_NAMESPACE, spec.name, remoteName);
1444
+ const mintedBy = mintedNames.get(namespacedName);
1445
+ if (mintedBy !== undefined) {
1446
+ dropped.push({
1447
+ tool: inlineUntrusted(t.name),
1448
+ reason: mintedBy === t.name
1449
+ ? `this server listed this tool name more than once in one listing; the first entry is mounted as "${namespacedName}" and the repeat is dropped (one name cannot denote two tools)`
1450
+ : `name collides with an earlier tool of this server: both mount as "${namespacedName}" after namespacing (only [a-zA-Z0-9_-] survives), and one name cannot denote two tools`,
1451
+ });
1452
+ continue;
1453
+ }
1454
+ mintedNames.set(namespacedName, t.name);
1281
1455
  const hintAxis = mcpAxisFor(namespacedName, t.annotations);
1282
1456
  const axis = applyCallerAxisOverride(namespacedName, hintAxis, spec.toolAxes?.[remoteName]);
1283
1457
  if (axis)
@@ -1397,3 +1571,8 @@ function asServerWarning(spec, err) {
1397
1571
  warning.code = "mcp.server_unavailable";
1398
1572
  return warning;
1399
1573
  }
1574
+ function listingIncompleteWarning(server, flag) {
1575
+ const warning = new Error(`mcp: server "${inlineUntrusted(server, 160)}" listed its tools INCOMPLETELY — ${listingIncompleteNote(flag)}. The tools beyond the ${flag.pages} page${flag.pages === 1 ? "" : "s"} retrieved are NOT mounted for this task.`);
1576
+ warning.code = "mcp.listing_incomplete";
1577
+ return warning;
1578
+ }
@@ -7,19 +7,36 @@
7
7
  * semantics; who may mint one, where it is stored, and where in the gate it is consumed live in
8
8
  * `permission-rule-store.ts`, `permission-rule-consent.ts` and `hooks.ts` respectively.
9
9
  *
10
- * ## The floor: only a single simple command can ever be matched
10
+ * ## The floor: what a rule may name, and what a rule may match
11
11
  *
12
- * Everything a shell can use to run a SECOND program behind an admitted first oneoperators,
13
- * substitution, subshells, backgrounding, and **redirection** — puts the command outside this lane
14
- * entirely: it falls back to the pre-existing chain and asks. That includes redirection specifically,
15
- * which is a deliberate registered divergence from upstream (upstream strips redirections before
16
- * matching; with this repo's write gate not covering the shell tool, stripping would turn a rule as
17
- * innocuous as `Bash(ls)` into a licence for `ls > ~/.ssh/authorized_keys`).
12
+ * Everything a shell can use to run a program the text does not NAMEsubstitution, subshells,
13
+ * backgrounding, escapes, line breaks, and **redirection** — puts the command outside this lane
14
+ * entirely: it falls back to the pre-existing chain and asks. Redirection specifically is a deliberate
15
+ * registered divergence from upstream (upstream strips redirections before matching; with this repo's
16
+ * write gate not covering the shell tool, stripping would turn a rule as innocuous as `Bash(ls)` into a
17
+ * licence for `ls > ~/.ssh/authorized_keys`).
18
18
  *
19
- * The floor is `parseLeadingCommandName` the one simple-command parser already shared by the
20
- * read-only classifier, the reversibility probe, the coarse command-name policy and the skill tool
21
- * specifier. A second tokenizer would drift, and drift on a loosening face shows up as a circumvention rather
22
- * than as a test failure.
19
+ * CONNECTORS are the one construct the lane does speak for, and only in the EXACT form. `./gradlew
20
+ * build && ./gradlew test` is one thing a person reads and approves in one glance, and the whole of it
21
+ * is written into the rule; nothing is admitted that the rule text does not spell out end to end. The
22
+ * PREFIX form stays single-command on both sides — as a rule BODY (`Bash(a && b:*)` is refused) and as
23
+ * a MATCH (`Bash(npm:*)` does not admit `npm test && curl evil.example`, which is the whole reason the
24
+ * two forms are separated here rather than sharing one matcher arm). Upstream reaches the same
25
+ * placement through a per-segment evaluation of its full decision chain, where a segment DENY is
26
+ * returned strictly before any whole-string allow; this lane's equivalent is that the deny/ask layer
27
+ * (`permission-rule-org.ts`) judges every segment and runs ahead of the allow lane at the gate.
28
+ *
29
+ * The floor is `parseLeadingCommandName` + `splitShellCompoundSegments` — the one simple-command parser
30
+ * and the one segmentation, both already shared with the read-only classifier, the reversibility probe,
31
+ * the coarse command-name policy and the skill tool specifier. A second tokenizer would drift, and drift
32
+ * on a loosening face shows up as a circumvention rather than as a test failure.
33
+ *
34
+ * argv[0] may carry a PATH prefix here (`./gradlew`, `/usr/bin/git`) — opt-in at the shared parser and
35
+ * used by this lane alone. The bare-name requirement belongs to the argv[0]-NAME filters, which compare
36
+ * one token against a name set; this lane compares the whole command line, so a path prefix is not a way
37
+ * past anything — it IS the text that was approved. No basename folding follows from it: `./gradlew`
38
+ * and `gradlew` stay two different commands to the matcher. The single place a basename is taken is the
39
+ * interpreter refusal below, which must read `/usr/bin/node` as `node`.
23
40
  *
24
41
  * ## Normalization order is load-bearing
25
42
  *
@@ -121,11 +138,64 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
121
138
  * any program you like" — that is not a shape a single click can be understood to have authorized.
122
139
  *
123
140
  * The check is on argv[0] of the prefix, not on the prefix being exactly one word: `node -e:*` is the
124
- * same licence spelled longer. Deliberately strict-side it costs the ability to persist a rule like
141
+ * same licence spelled longer. It is on argv[0]'s BASENAME, not its spelling: `/usr/bin/node:*` and
142
+ * `./node:*` grant precisely what `node:*` grants, and a table consulted with the full path would be a
143
+ * table any path prefix walks around (upstream takes the same basename before consulting its own
144
+ * interpreter set). Deliberately strict-side — it costs the ability to persist a rule like
125
145
  * `python manage.py migrate:*` (which an import reports as skipped rather than dropping silently), and
126
146
  * an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
147
+ *
148
+ * TWO groups, and the distinction matters when the table is next edited:
149
+ * · LANGUAGE interpreters (`node`, `python`, `ruby`, …) — the argument IS a program. This half is
150
+ * wider than upstream's own set, deliberately, and is the argument the paragraph above makes.
151
+ * · LAUNCHERS — a program whose argument is another program to run: the shells, the environment and
152
+ * privilege wrappers, the schedulers and resource wrappers, the tracers, and the shell BUILTINS
153
+ * that dispatch (`command`, `builtin`). This half is upstream's set, adopted verbatim rather than
154
+ * reasoned out row by row: it is the same question upstream answers with the same mechanism (its
155
+ * set is consulted on argv[0]'s basename too), and every row passes this table's own first axis —
156
+ * a body whose use is naming a program to execute is refused. They were missing, and each was one
157
+ * token in front of the licence the language half already refuses: `command node:*`, `timeout 9
158
+ * node:*` and `nice node:*` all grant exactly what `node:*` grants.
159
+ *
160
+ * KNOWN RESIDUAL, stated rather than hidden, and it is a property of the instrument rather than of this
161
+ * particular list: a positive list of NAMES cannot be made complete, and two independent review rounds
162
+ * enumerated escapes faster than rows could be added. Three shapes, none of which a longer list fixes:
163
+ * · a version- or distribution-suffixed spelling of a listed interpreter (`python3.12`, `nodejs`);
164
+ * · a launcher nobody listed — the set of programs that run another program has no boundary, and each
165
+ * round produced more of them;
166
+ * · a spelling that defeats the NAME question entirely: `/proc/self/exe` (basename `exe`, the running
167
+ * shell), a symlink or a renamed binary, a BusyBox applet, a dynamic loader invoked directly.
168
+ * Matching a family would need a pattern rather than a set, with its own false-positive surface
169
+ * (`node-gyp`, `timeout-monitor`), and no pattern addresses the third shape at all. What carries the
170
+ * residue is therefore NOT this table: it is the three standing fences — the org deny/ask layer runs
171
+ * ahead of this lane and cannot be silenced by it, a mandated ask is not rule-clearable, and the
172
+ * narrower exact candidate (plus minting nothing) is always on the same card. This table's job is to
173
+ * keep the OBVIOUS one-token licence off a card a person clicks once, not to be a boundary.
127
174
  */
128
175
  export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
176
+ /**
177
+ * Shell KEYWORDS, refused as a segment's `argv[0]` everywhere this lane reads a command.
178
+ *
179
+ * The floor this lane stands on extracts the first TOKEN of a segment and calls it the command name.
180
+ * That identification is what every comparison here rests on — the interpreter refusal, the deny/ask
181
+ * layer's per-segment judgement, and a person's reading of the rule text. For a control structure it is
182
+ * simply false. `for x in once; do curl evil.example; done` splits into three segments whose first
183
+ * tokens are `for`, `do` and `done`; all three are ordinary bare words, so the floor accepts each,
184
+ * `curl` is named by nothing, and a published `deny Bash(curl:*)` matches none of them while bash runs
185
+ * curl. A whole loop body — any number of programs — hides behind three tokens that name no program.
186
+ *
187
+ * Refusing the keyword puts the whole command outside the lane, which is the honest answer: no rule can
188
+ * be minted for it and no rule matches it, so it asks. Recovering these shapes properly needs a real
189
+ * shell grammar (upstream has one — it parses to a syntax tree and reads the commands out of the
190
+ * structure, so a keyword is never mistaken for a program); a token-level lane cannot, and pretending
191
+ * otherwise is the loosening direction.
192
+ *
193
+ * `!` is here for the same reason with a sharper edge: it is a keyword that PREFIXES a real command, so
194
+ * `! node -e …` is the `node` licence the interpreter table exists to refuse, wearing one extra token.
195
+ * `{`/`}`/`[[`/`]]` are already refused by the floor's argv[0] metacharacter rule; listed anyway,
196
+ * because that rule belongs to another module and is not this lane's to depend on.
197
+ */
198
+ export declare const SHELL_RESERVED_WORDS: ReadonlySet<string>;
129
199
  /**
130
200
  * design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
131
201
  * (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
@@ -215,10 +285,46 @@ export declare function formatAllowRuleText(command: string, match: PersistedRul
215
285
  * Does this rule's command pattern admit `command`?
216
286
  *
217
287
  * `command` is the raw tool argument: the floor and the folding happen HERE, in that order, so no
218
- * caller can accidentally match a folded compound. Returns false for anything that is not a single
219
- * simple command including every redirection form.
288
+ * caller can match a command this lane has not read. Returns false for every redirection, substitution,
289
+ * subshell, backgrounded or escaped form.
290
+ *
291
+ * **A PREFIX rule never admits a compound.** This is the single load-bearing line of the connector
292
+ * widening, and it is checked on the MATCH side rather than left to the mint side: `Bash(npm:*)` is an
293
+ * ordinary, legitimately mintable rule, and if the "does this command start with `npm `" arm were
294
+ * allowed to see a compound at all, that rule would admit `npm test && curl evil.example` — one stored
295
+ * yes to a build command turned into a standing yes to whatever is chained behind it. An EXACT rule has
296
+ * no such reach by construction: it admits one string, the one it spells.
220
297
  */
221
298
  export declare function ruleAdmitsCommand(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
299
+ /**
300
+ * The same match, asked the DENY/ASK layer's question — "does this rule speak about this program run?".
301
+ *
302
+ * Split from {@link ruleAdmitsCommand} because the two differ on one axis that decides real cases: a
303
+ * quoted operator. `curl "https://x/?a=1&b=2"` is ONE command bash runs, and the `&` in a query string
304
+ * is not a connector; the matching side refuses it anyway (a rule text is a spelling with no operator
305
+ * characters in it at all — the historical rule-face contract), and the deny side inheriting that
306
+ * refusal made `deny Bash(curl:*)` silent on the commonest spelling of the very program it names
307
+ * (adversarial round 3).
308
+ *
309
+ * The asymmetry only ever runs one way: this predicate reads MORE commands than the matching one, never
310
+ * fewer. A shape only the ALLOW side could read would be a standing approval no published policy could
311
+ * see — the inversion this whole ticket exists to prevent.
312
+ */
313
+ export declare function ruleAdmitsProgramRun(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
314
+ /**
315
+ * The segments of `command` as the deny/ask layer must judge them, or `undefined` for a command this
316
+ * lane cannot read.
317
+ *
318
+ * A tightening rule speaks about a PROGRAM RUN, and a compound runs several. `Bash(curl:*)` published
319
+ * as a deny means "this machine does not make that call", and reading `npm test && curl evil.example`
320
+ * as one unmatched blob answered that with silence — the shape the widening above would otherwise make
321
+ * permanently approvable. Exported (rather than folded into a matcher here) because the layer that
322
+ * needs it holds the rules: this module owns what a command IS, `permission-rule-org.ts` owns what the
323
+ * organization says about each part of it.
324
+ *
325
+ * A single simple command yields a one-element list, so a deny that matched before matches identically.
326
+ */
327
+ export declare function ruleLaneSegmentsOf(command: string): readonly string[] | undefined;
222
328
  /**
223
329
  * Is `path` inside (or equal to) `root`? Word-boundary containment on the path separator, so `/a` does
224
330
  * not contain `/ab`. Both sides are expected to be canonical already.
@@ -271,13 +377,26 @@ export interface RuleSuggestion {
271
377
  * shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
272
378
  * characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
273
379
  *
274
- * Every produced candidate must survive the round trip — parse as a rule AND admit the very command
275
- * it was minted from. True by construction (a word-boundary lexicon prefix of a folded simple command
276
- * is exactly the matcher's two arms); enforced anyway, fail-closed: a candidate that would not
277
- * round-trip is silently not offered, since offering an option redemption would refuse is worse than
278
- * offering one fewer.
380
+ * Every produced candidate must survive the round trip — parse as a rule, come back as the match form
381
+ * this seat is offering, AND admit the very command it was minted from. Enforced on BOTH seats,
382
+ * fail-closed: a candidate that would not round-trip is silently not offered, since offering an option
383
+ * redemption would refuse is worse than offering one fewer.
384
+ *
385
+ * The FORM half of that check is not decoration. A command may end in the rule grammar's own prefix
386
+ * marker — `rm :*` is a legal thing to type — and wrapping it as an exact rule produces the text
387
+ * `Bash(rm :*)`, which the validator reads back as the PREFIX rule `Bash(rm:*)` over the command `rm`.
388
+ * Trusting the exact seat's own intent and labelling that result `match: "exact"` put a blanket
389
+ * every-`rm` rule on the card under the narrowest option's name, one click from being persisted (and,
390
+ * for a lexicon command like `git status :*`, emitted twice — once mislabelled, once as the real prefix
391
+ * candidate). The seat therefore believes the PARSER about what it got back, never its own request.
392
+ *
393
+ * A COMPOUND (`./gradlew build && ./gradlew test`) fills the exact seat and only that one: the offered
394
+ * rule spells the whole chain and admits exactly it. That is the shape this seat was missing — the
395
+ * ordinary build invocation is a connector chain, and a card that could offer nothing for it made every
396
+ * such command a fresh question forever, with no way for an answer to accumulate.
279
397
  *
280
- * Returns an empty array for anything the rule lane cannot speak for (compounds, redirections,
281
- * substitutions) — the card then simply carries no "don't ask again" option, which is the honest answer.
398
+ * Returns an empty array for anything the rule lane cannot speak for (redirections, substitutions,
399
+ * subshells, backgrounding) — the card then simply carries no "don't ask again" option, which is the
400
+ * honest answer.
282
401
  */
283
402
  export declare function suggestRulesForCommand(command: string): RuleSuggestion[];