@sema-agent/core 5.53.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.54.0 — 2026-08-22
4
+
5
+ ### Fixed
6
+ - **Compound commands are adjudicated segment-wise** (#353 hemostat): the rule lane's whole-string
7
+ floor made every compound spelling invisible to it — an org/user **deny** on `curl` never fired on
8
+ `git status && curl evil.example` (fail-open past the deny into the classifier lane), suggestions
9
+ came back empty, and an exact compound rule could not be minted or matched. Now: any-segment deny
10
+ refuses the whole string before any allow path; whole-string exact allow admits; all-segments-allow
11
+ admits; anything else asks with a per-segment suggestion batch (capped at 5). A path-prefixed
12
+ argv0 (`/usr/bin/curl`) joins the rule lane as text. A build-tool allowlist was considered and
13
+ rejected (not a CC form).
14
+ - **MCP listings walk their pagination** (#381): tools/list and resources/list read only the FIRST
15
+ page — later tools silently never mounted and nothing disclosed it. All list sites now walk pages
16
+ through one bounded chokepoint (20-page cap; cursor-loop guard; a continuation needs a fundable
17
+ remainder — min(250ms, budget/10)); an unfinished walk is disclosed with a typed reason
18
+ (`cursor_invalid` | `page_error` | `cursor_loop` | `page_cap` | `budget_exhausted`) on the model
19
+ face, the operator warning lane (`mcp.listing_incomplete`), and `McpRefreshResult.listingIncomplete`;
20
+ a refresh that cannot prove an entry gone retains it. **Closed-set note**: `McpListingIncomplete.reason`
21
+ gains `budget_exhausted` (+ optional `budgetMs`) — a downstream exhaustive switch needs the case.
22
+ - **Ledger stores enforce single-writer-per-directory at construction** (#382): checkpoint /
23
+ background-agent / workflow-run each documented a boot lock they did not hold — two OS processes
24
+ could each win the SAME once-only approval, and one process's compaction could erase another's
25
+ fsync'd rows. The fence now lives with the authority (`shared-ledger.ts` bootstrap takes
26
+ `<dir>/LOCK`; refcount-joined in-process; crashed-owner locks pruned through a named claim gate);
27
+ a second OS process is refused at construction. New exports: `FileStoreLockError`,
28
+ `FileStoreLockErrorCode` (`store.dir_in_use` | `store.dir_claiming` | `store.lock_unreadable`).
29
+ - **The workflow budget gate re-judges after admission** (#383): the ceiling was checked only at
30
+ dispatch time against a pre-batch snapshot — a 40-item fan-out overshot 26x (serial dispatch
31
+ identically) and the refusal text blamed "a loop without a termination condition". The
32
+ authoritative check now runs after `sem.acquire()` against live spend; refused calls settle as
33
+ failed agent rows carrying `workflow.budget_exceeded`; a fully-cached resume is never gated;
34
+ bad budget values refuse loudly; budget and agent-cap refusals each name their own cause.
35
+ - **The workflow steer marker is unpredictable** (test P0-KPI #9): the workflow lane's steer
36
+ correlation marker was a monotone counter (`steer-1`…) — untrusted content could pre-claim the
37
+ tag and self-declare correlation, the hole the subagent steer seat had already closed
38
+ (design/122 r1-m3). Both seats now single-source the same 12-hex fragment; markers are
39
+ per-delivery distinct, ephemeral, never journaled.
40
+ - **Pre-release rescan dispositions (seven, fixed in-tree before publish)**: the compound-splitter
41
+ extraction was not byte-identical for the readonly classifier (`ls; 2>/dev/null` had flipped from
42
+ ask to auto-allow — restored); a near-exhausted MCP walk budget was spent as a real request and
43
+ its cut-off blamed on the server (now core-attributed, see #381 above); a stuck paginator's
44
+ re-served page was appended twice and the duplicate blamed on a namespacing collision (rewind of
45
+ provably byte-identical re-serves; drop reasons split); a failed `BootLock.release()` left a stale
46
+ holder able to delete its successor's live fence (release is one-shot again); a zero-ref fence
47
+ entry was joinable without consulting the disk (re-validated, foreign owners refused by name);
48
+ `FileWorkflowRunStore.close()`'s doc credited a backend factory that never constructs it;
49
+ `docs/ARCHITECTURE.md`'s lock coverage contradicted `docs/KNOWN-ISSUES.md`.
50
+ - Behavior narrowings (named): org/user denies now reach compound spellings (previously fail-open);
51
+ a second OS process over a ledger directory refuses at construction instead of corrupting;
52
+ `compactNow()` on a closed store throws; workflow budget bad values refuse at construction;
53
+ budget-refused calls appear as failed agent rows; a second `dispose()` after a failed fence
54
+ release removes nothing; an in-process re-open over an unreadable stranded lock refuses
55
+ (`store.lock_unreadable`); a stranded fence taken by another process refuses (`store.dir_in_use`);
56
+ a failed fence release warns (was silent); `ls; 2>/dev/null` asks again (restores the 5.53
57
+ contract).
58
+
3
59
  ## 5.53.0 — 2026-08-21
4
60
 
5
61
  ### Fixed
@@ -356,6 +356,10 @@ export interface SubagentSteerHandle {
356
356
  */
357
357
  resume?: (content: string) => Promise<string>;
358
358
  }
359
+ /** design/122 r1-m3 — UNPREDICTABLE correlation-marker fragment (uuid random tail, 12 hex chars). A marker
360
+ * must not be guessable by untrusted content — the old `steer-a<seq>` global counter let injected text
361
+ * pre-claim "[steer-aN]" and self-declare correlation; the same fix applies to the new resume markers. */
362
+ export declare function markerFragment(): string;
359
363
  /**
360
364
  * design/122 D2 — build the `resume` verb for one delegation (closure over the ledger + delegation runner —
361
365
  * NEVER the parent tool ctx / spec builder, r1-m5). Validation order: TTL sweep (MAJOR-1) → disposed?
@@ -436,7 +436,7 @@ function createBgActivityBeat(parentToolCallId, emitTick, noteActivity) {
436
436
  emitTick(starts);
437
437
  };
438
438
  }
439
- function markerFragment() {
439
+ export function markerFragment() {
440
440
  return uuidv7().replace(/-/g, "").slice(-12);
441
441
  }
442
442
  function createSteerHandle(stream, parentToolCallId, agentName, settled, retain) {
@@ -85,6 +85,39 @@ export interface McpDroppedTool {
85
85
  tool: string;
86
86
  reason: string;
87
87
  }
88
+ /**
89
+ * The completeness verdict of a walked STANDARD MCP list method (`tools/list`, `resources/list` — see
90
+ * {@link walkMcpListPages}). ABSENT means the walk reached the end of pagination and the aggregated
91
+ * listing IS the server's full list. PRESENT means it is not, and the listing must not be presented —
92
+ * or DIFFED — as if it were: an entry missing from a truncated listing is unproven-absent, not
93
+ * withdrawn (that inference is exactly what made a paginating server's second-page tools read as
94
+ * `removed`).
95
+ * - `page_cap` — {@link MAX_MCP_LIST_PAGES} pages were walked with a cursor still pending;
96
+ * - `cursor_loop` — the server handed back a cursor already used in this walk (a paginator that does
97
+ * not advance); the walk stops rather than spinning to the cap re-appending the same page;
98
+ * - `cursor_invalid` — the server sent a `nextCursor` that is not a string (`null` excepted: that is
99
+ * JSON's absent optional). It cannot be followed and it is not proof of the end, so the walk stops
100
+ * and says so instead of certifying a short listing as complete;
101
+ * - `page_error` — a CONTINUATION page failed. The pages already retrieved are kept and the failure
102
+ * text carried. A FIRST-page failure is never this: it still throws (a caller's "no listing at all"
103
+ * must not degrade into "an empty listing");
104
+ * - `budget_exhausted` — CORE stopped the walk: the time this listing is allowed to spend
105
+ * ({@link walkMcpListPages}'s `budgetMs`) had too little left to fund another request
106
+ * ({@link MIN_MCP_PAGE_BUDGET_MS}), so none was sent. Distinct from `page_error` on purpose — the
107
+ * server neither failed nor was asked. Issuing that request anyway would hand the SDK a
108
+ * near-zero timeout, and its -32001 would arrive here wearing the server's failure clothes.
109
+ * `error` is SERVER-authored text — neutralize it before it rides any model/operator face.
110
+ */
111
+ export interface McpListingIncomplete {
112
+ reason: "page_cap" | "cursor_loop" | "cursor_invalid" | "page_error" | "budget_exhausted";
113
+ /** Pages actually retrieved before the walk stopped (≥ 1 — a zero-page walk throws instead). */
114
+ pages: number;
115
+ error?: string;
116
+ /** The walk's total time budget in ms. Present ONLY on `budget_exhausted`, where it is the whole
117
+ * actionable content of the verdict (which bound to raise); every other reason leaves it absent
118
+ * rather than reporting a budget that had nothing to do with the stop. */
119
+ budgetMs?: number;
120
+ }
88
121
  /** Tools materialized from one or more MCP servers, plus a disposer to disconnect them. */
89
122
  export interface MaterializedMcp {
90
123
  tools: AgentTool[];
@@ -125,10 +158,17 @@ export interface MaterializedMcp {
125
158
  pendingRemovals: string[];
126
159
  };
127
160
  /**
128
- * One entry per server that failed to connect / list its tools and was SKIPPED (fail-open,
129
- * design/29). The task proceeds with the healthy servers' tools a single bad server (missing
130
- * stdio command, unreachable URL) must never brick every task in the scenario. The Runner forwards
131
- * these to `onError(phase:"mcp")`. Empty when every server connected.
161
+ * The OPERATOR warning lane for this materialization (the Runner forwards each entry to
162
+ * `onError(phase:"mcp")`). Empty when every server connected and listed completely. Two populations,
163
+ * distinguishable by the `code` each Error carries a consumer must NOT read mere presence as "this
164
+ * server was skipped":
165
+ * - `mcp.server_unavailable` — a server that failed to connect / list its tools and was SKIPPED
166
+ * (fail-open, design/29): the task proceeds with the healthy servers' tools, because a single bad
167
+ * server (missing stdio command, unreachable URL) must never brick every task in the scenario;
168
+ * - `mcp.listing_incomplete` — a server that DID connect but whose `tools/list` pagination walk did
169
+ * not reach the end ({@link McpListingIncomplete}): its tools past the stopping point are not
170
+ * mounted this task, which is a capability loss the operator must see rather than infer from a
171
+ * short tool list.
132
172
  */
133
173
  warnings: Error[];
134
174
  /**
@@ -186,7 +226,13 @@ export interface MaterializedMcp {
186
226
  dispose: () => Promise<void>;
187
227
  }
188
228
  /** [1605] One per-server entry of {@link MaterializedMcp.refresh}. `added`/`removed` are namespaced
189
- * (`mcp__<server>__<tool>`) names; `tools`/`axes`/`dropped` are present only on `"refreshed"`. */
229
+ * (`mcp__<server>__<tool>`) names; `tools`/`axes`/`dropped` are present only on `"refreshed"`.
230
+ * The model-facing refresh receipt renders each `dropped` entry with ITS OWN `reason` (schema
231
+ * gate, same-listing name collision, …) — never a blanket label; a consumer matching that receipt
232
+ * text should key on the tool name, not on any fixed reason wording. Note the startup-time
233
+ * sibling: two DECLARED servers whose names normalize to one prefix refuse the whole mount at
234
+ * connect (`config.mcp_server_name_collision`, both original spellings named) — a deployment
235
+ * carrying such a pair learns at the first materialize, with the rename spelled out in the error. */
190
236
  export interface McpRefreshResult {
191
237
  server: string;
192
238
  /** The server's namespaced-name prefix (`mcp__<normalized-server>__`) — the consumer's SPLICE
@@ -199,11 +245,37 @@ export interface McpRefreshResult {
199
245
  status: "refreshed" | "not_connected" | "failed" | "revoked";
200
246
  toolCount: number;
201
247
  added: string[];
248
+ /**
249
+ * Names that were mounted before this refresh and are no longer in the roster it returns — i.e. an
250
+ * assertion that the server withdrew them. It is therefore EMPTY whenever {@link listingIncomplete}
251
+ * is present, and empty as a FACT rather than a policy: an incomplete walk cannot prove absence, so
252
+ * it retains what it could not prove gone (see `listingIncomplete`) and nothing leaves. `added` is
253
+ * unaffected — a name that IS in the partial listing was really listed, whether or not the walk
254
+ * finished (#381).
255
+ */
202
256
  removed: string[];
203
257
  tools?: AgentTool[];
204
258
  axes?: McpToolAxis[];
205
259
  dropped?: McpDroppedTool[];
260
+ /**
261
+ * Set on `not_connected` / `failed` / `revoked`; ALSO set on a `"refreshed"` entry whose listing walk
262
+ * did not finish (see {@link listingIncomplete}) — a refresh that produced usable tools is not a
263
+ * failure, so the status stays `refreshed` and the incompleteness rides here.
264
+ */
206
265
  error?: string;
266
+ /**
267
+ * #381 — present when this entry's `tools/list` walk did NOT reach the end of pagination. `tools` is
268
+ * then a MERGE: everything the partial listing did return, plus the previously-mounted entries whose
269
+ * absence it cannot prove (so the consumer's replace-the-whole-prefix swap withdraws nothing, and
270
+ * `removed: []` describes a roster that really lost nothing). Absent on a complete walk and on every
271
+ * non-`refreshed` status.
272
+ *
273
+ * RESIDUAL, honest: the model-facing `RefreshMcpTools` receipt renders a refreshed entry's line from
274
+ * added/removed/excluded/dropped only, so this fact reaches the STRUCTURED face (`details.results`)
275
+ * and the operator, not the receipt text — rendering it there is a prepare-task change, filed rather
276
+ * than smuggled in here.
277
+ */
278
+ listingIncomplete?: McpListingIncomplete;
207
279
  }
208
280
  /** design/99 §E9 — projected per-server MCP status (see {@link MaterializedMcp.statuses}). NOTE: `serverInfo`
209
281
  * and `error` are SERVER-controlled strings (verbatim from the remote) — UNTRUSTED; a consumer rendering them
@@ -490,6 +562,97 @@ export declare function applyCallerAxisOverride(name: string, hint: McpToolAxis
490
562
  * for the discrimination nails; the message is SERVER-authored and is only ever classified here, never
491
563
  * trusted as instructions. */
492
564
  export declare function classifyDirReadInvalidParams(message: string): "not_found" | "not_directory";
565
+ /**
566
+ * The content key {@link walkMcpListPages}'s cursor-loop arm uses to recognize an entry it already
567
+ * collected: the entry's own JSON, which is what the server sent and what a re-served page repeats
568
+ * verbatim. `undefined` for anything unserializable — an entry that cannot be keyed is never treated as
569
+ * a repeat, so the failure direction is "kept, and disclosed by the lane that judges it" rather than
570
+ * "silently gone".
571
+ *
572
+ * NOT a semantic identity: two spellings of the same entry (different key order, a description the
573
+ * server re-rendered) read as different entries here and both survive. That is the safe direction —
574
+ * this key only ever authorizes a DELETION, so it must be exact and never clever.
575
+ *
576
+ * Exported for the walker's own unit pins (same reason {@link walkMcpListPages} is): its bound is a
577
+ * behavior, and a pin that re-declared the bound locally would pass while the real one drifted.
578
+ */
579
+ export declare function listEntryFingerprint(entry: unknown): string | undefined;
580
+ /**
581
+ * #381 — walk a paginated MCP list method to the END (`{cursor?}` → `{items, nextCursor?}`), bounded.
582
+ *
583
+ * Every spec-standard MCP list method is paginated the same way: absence of `nextCursor` means "that
584
+ * was the last page". A single request therefore returns the WHOLE list only for servers that choose
585
+ * not to paginate — and a server may start paginating at any time, with no protocol change and no
586
+ * malice, the moment its list outgrows its own page size. Treating page one as the complete list is
587
+ * both a capability loss (the rest is never mounted) and, at any call site that DIFFS two listings, an
588
+ * active falsehood about the server's inventory.
589
+ *
590
+ * Contract:
591
+ * - page ONE is fetched with the caller's own historic parameters (`cursor === undefined`), so a
592
+ * non-paginating server's wire traffic is byte-identical to before;
593
+ * - a FIRST-page failure propagates unchanged — every caller's failure shape (skipped server,
594
+ * `failed` refresh, per-server attributed aggregate error) is built on that throw;
595
+ * - a CONTINUATION failure keeps the pages already retrieved and reports {@link McpListingIncomplete}
596
+ * (`page_error`) — fail-open WITH disclosure, the same law {@link readDirViaExtension} follows —
597
+ * except an ABORT, which still propagates (a cancelled walk is not a short listing);
598
+ * - a repeated cursor stops the walk (`cursor_loop`) instead of spinning to the cap, AND undoes the
599
+ * entries that page re-served (a paginator that does not advance is typically one answering with a
600
+ * page already collected, and keeping it is how one entry gets listed twice) — judged by
601
+ * `identityOf` where the caller can key its entries, whole-page otherwise; an unreadable cursor
602
+ * stops the walk as `cursor_invalid` rather than passing for the end of the listing;
603
+ * - {@link MAX_MCP_LIST_PAGES} pages with a cursor still pending stops it too (`page_cap`);
604
+ * - a remainder too short to fund a real request stops it as `budget_exhausted` — core's own doing,
605
+ * named as such (see {@link MIN_MCP_PAGE_BUDGET_MS}).
606
+ * SIZE, stated rather than assumed: a single page has never been bounded here (a server could always
607
+ * answer with an arbitrarily long list), and walking multiplies that existing exposure by at most the
608
+ * page cap — a bounded multiplier, not a new unbounded surface. Per-entry admission stays where it
609
+ * already is (the intake schema gate, which also drops a name a later page repeats).
610
+ *
611
+ * TIME, the reason `budgetMs` exists (codex review, confirmed): the page cap bounds REQUESTS, not
612
+ * latency. Giving every page its own full request timeout would let a server that stalls each page hold
613
+ * a connect (or a refresh) for up to twenty timeouts — twenty minutes at the SDK's 60s default, where
614
+ * an unpaginated listing cost one. So the WALK gets the budget a single listing used to have: page one
615
+ * is issued with the full amount (byte-identical to the pre-walk request) and each continuation gets
616
+ * only what is left. ACCEPTED COST, stated: a genuinely slow server whose pages together outlast that
617
+ * bound is truncated where a single-request listing would not have been — but that truncation is
618
+ * DISCLOSED, while the alternative is an undisclosed twenty-fold latency ceiling on the task's startup
619
+ * path. The bound is the caller's own configured request timeout, not a new knob, so a deployment that
620
+ * needs more already has the dial. `fetchPage` receives the remaining budget and MUST spend it as that
621
+ * request's timeout — the deadline is cooperative, since only the caller can pass a timeout to its own
622
+ * transport — and must REPORT that same number if the request then times out: a page cut off at 3ms
623
+ * whose failure text names the whole listing budget describes a wait that never happened.
624
+ *
625
+ * NOT merged with {@link readDirViaExtension}, which walks the same way but owns CC-anchored
626
+ * first-page `InvalidParams` discrimination (not-a-directory vs not-found) and its own three-state
627
+ * flags; folding it in here would put that CC semantics into the standard-method path where it has no
628
+ * meaning.
629
+ *
630
+ * Exported for the walker's own unit pins — its arms are failure/deadline states that no fixture server
631
+ * can drive deterministically. Every production consumer goes through the three call sites in this file.
632
+ */
633
+ export declare function walkMcpListPages<T>(fetchPage: (cursor: string | undefined, remainingMs: number) => Promise<{
634
+ items: T[];
635
+ nextCursor?: string;
636
+ cursorInvalid?: boolean;
637
+ }>, opts: {
638
+ budgetMs: number;
639
+ onPage?: () => void;
640
+ signal?: AbortSignal;
641
+ /** A key for one entry's CONTENT — equal keys mean the same listing entry, served again.
642
+ * `undefined` for an entry the caller cannot key. Used ONLY by the cursor-loop arm, to tell a
643
+ * re-served page from a page that merely arrived with a broken cursor; absent ⇒ that arm cannot
644
+ * tell them apart and falls back to dropping the whole page (see it for the trade).
645
+ *
646
+ * CONTENT, not name: an entry's name is what the CALLERS' admission gates key on, and they judge
647
+ * each entry on its own (a tool whose schema they refuse is dropped, and a later entry of the same
648
+ * name is then free to mount). Keying this on the name would let this walk delete the second
649
+ * entry before any of that ran — the only admissible definition of a tool, removed on the theory
650
+ * that a name cannot appear twice, which is a rule the layer below owns and applies differently. */
651
+ fingerprintOf?: (item: T) => string | undefined;
652
+ }): Promise<{
653
+ items: T[];
654
+ incomplete?: McpListingIncomplete;
655
+ }>;
493
656
  /**
494
657
  * RB-408 G12 — `tools/call` result parse that accepts the MCP `2026-07-28` widening of
495
658
  * `structuredContent` from "a JSON object" to ANY JSON value (the same revision that relaxes
package/dist/core/mcp.js CHANGED
@@ -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";
@@ -719,7 +720,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
719
720
  if (r.status === "fulfilled") {
720
721
  const s = r.value;
721
722
  clients.push(s.client);
722
- 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 });
723
724
  tools.push(...s.tools);
724
725
  toolAxes.push(...s.axes);
725
726
  if (s.instructions) {
@@ -740,6 +741,8 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
740
741
  resourceServers.push(s.resourceServer);
741
742
  for (const d of s.dropped)
742
743
  droppedTools.push({ server: inlineUntrusted(spec.name, 160), ...d });
744
+ if (s.listingIncomplete)
745
+ warnings.push(listingIncompleteWarning(spec.name, s.listingIncomplete));
743
746
  statuses.push(s.status);
744
747
  }
745
748
  else {
@@ -781,12 +784,17 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
781
784
  }
782
785
  try {
783
786
  const listed = await listToolsLenient(h.client);
784
- cacheMcpToolMetadata(h.client, listed.tools);
785
- 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);
786
793
  const newNames = serverTools.map((t) => t.name);
787
- const added = newNames.filter((n) => !h.toolNames.includes(n));
788
- const removed = h.toolNames.filter((n) => !newNames.includes(n));
789
- 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;
790
798
  results.push({
791
799
  server: h.name,
792
800
  prefix: prefixOf(h.name),
@@ -797,6 +805,9 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
797
805
  tools: serverTools,
798
806
  axes: serverAxes,
799
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
+ : {}),
800
811
  });
801
812
  }
802
813
  catch (err) {
@@ -804,7 +815,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
804
815
  server: h.name,
805
816
  prefix: prefixOf(h.name),
806
817
  status: "failed",
807
- toolCount: h.toolNames.length,
818
+ toolCount: h.tools.length,
808
819
  added: [],
809
820
  removed: [],
810
821
  error: inlineUntrusted(namedMcpFailureText(err), 240),
@@ -875,6 +886,110 @@ function resourceLine(r) {
875
886
  function serverNames(servers) {
876
887
  return servers.map((r) => r.server).join(", ") || "(none)";
877
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
+ }
878
993
  async function readDirViaExtension(rs, uri, signal, timeoutMs, watchdog) {
879
994
  const resources = [];
880
995
  let cursor;
@@ -918,10 +1033,13 @@ function renderDirChildren(server, uri, children, flags) {
918
1033
  if (flags?.cursorInvalid) {
919
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]`);
920
1035
  }
1036
+ if (flags?.listing)
1037
+ notes.push(listingIncompleteNote(flags.listing));
921
1038
  const detailFlags = {
922
1039
  ...(flags?.truncated ? { truncated: true } : {}),
923
1040
  ...(flags?.incomplete !== undefined ? { incomplete: true, error: flags.incomplete } : {}),
924
1041
  ...(flags?.cursorInvalid ? { incomplete: true, cursorInvalid: true } : {}),
1042
+ ...(flags?.listing ? { incomplete: true, listing: flags.listing } : {}),
925
1043
  };
926
1044
  const listing = children.length === 0
927
1045
  ? `(Empty directory: ${inlineUntrusted(uri)})`
@@ -959,6 +1077,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
959
1077
  const sections = [];
960
1078
  const all = [];
961
1079
  const errors = [];
1080
+ const incomplete = [];
962
1081
  for (const rs of targets) {
963
1082
  if (isServerRevoked(rs.server)) {
964
1083
  errors.push({ server: rs.server, error: "server revoked by the operator mid-session (request not sent)" });
@@ -973,16 +1092,30 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
973
1092
  const idleMs = mcpIdleTimeoutMs(rs.transportKind);
974
1093
  const watchdog = armMcpIdleWatchdog(rs.health, idleMs, signal);
975
1094
  try {
976
- const res = await rs.client.listResources(undefined, { signal: watchdog.combinedSignal, timeout: mcpToolTimeoutMs() });
977
- all.push(...res.resources.map((r) => ({ server: rs.server, ...r })));
978
- const lines = res.resources.map((r) => resourceLine(r));
979
- 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
+ }
980
1113
  }
981
1114
  catch (err) {
982
1115
  if (signal?.aborted)
983
1116
  throw err;
984
1117
  const msg = watchdog.idleSignal.reason === IDLE_WATCHDOG_ABORT_REASON
985
- ? `received no response for ${idleMs}ms (idle watchdog; set MCP_IDLE_TIMEOUT_STDIO / MCP_IDLE_TIMEOUT_HTTP to change this bound)`
1118
+ ? idleWatchdogTripText(idleMs)
986
1119
  : err instanceof Error
987
1120
  ? err.message
988
1121
  : String(err);
@@ -996,7 +1129,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
996
1129
  const body = sections.join("\n\n");
997
1130
  return {
998
1131
  content: [{ type: "text", text: sections.length ? delimitUntrusted("mcp resources", body) : "(no MCP resources)" }],
999
- details: { resources: all, ...(errors.length ? { errors } : {}) },
1132
+ details: { resources: all, ...(errors.length ? { errors } : {}), ...(incomplete.length ? { incomplete } : {}) },
1000
1133
  terminate: false,
1001
1134
  };
1002
1135
  },
@@ -1125,12 +1258,15 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
1125
1258
  return renderDirChildren(server, uri, r.resources, { truncated: r.truncated, incomplete: r.incomplete, cursorInvalid: r.cursorInvalid });
1126
1259
  watchdog.rearm();
1127
1260
  }
1128
- const res = await rs.client
1129
- .listResources(undefined, { signal: watchdog.combinedSignal, timeout: timeoutMs })
1130
- .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 } : {}) });
1131
1267
  const dirPrefix = uri.endsWith("/") ? uri : `${uri}/`;
1132
- const children = res.resources.filter((r) => typeof r.uri === "string" && r.uri !== uri && r.uri.startsWith(dirPrefix));
1133
- 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);
1134
1270
  }
1135
1271
  finally {
1136
1272
  watchdog.dispose();
@@ -1177,7 +1313,8 @@ const LenientListToolsResultSchema = {
1177
1313
  });
1178
1314
  }
1179
1315
  const nextCursor = data.nextCursor;
1180
- 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 } : {}) } };
1181
1318
  },
1182
1319
  };
1183
1320
  export function parseCallToolResultLenient(data) {
@@ -1192,7 +1329,19 @@ export function parseCallToolResultLenient(data) {
1192
1329
  }
1193
1330
  const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient };
1194
1331
  async function listToolsLenient(client, options) {
1195
- 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 } : {}) };
1196
1345
  }
1197
1346
  async function connectServer(spec, principal, onElicit, imageResizer, reminderDisclosure, isServerRevoked) {
1198
1347
  const elicitOn = spec.elicitation === true && onElicit !== undefined;
@@ -1254,7 +1403,9 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1254
1403
  tools: serverTools,
1255
1404
  axes: serverAxes,
1256
1405
  dropped,
1406
+ listedTools: listed.tools,
1257
1407
  ...(instructions ? { instructions } : {}),
1408
+ ...(listed.incomplete !== undefined ? { listingIncomplete: listed.incomplete } : {}),
1258
1409
  ...(resourceInfo && (resourceInfo.listAllowed || resourceInfo.readAllowed) ? { resourceServer: { server: spec.name, client, health, transportKind: idleKindOf(spec), ...resourceInfo } } : {}),
1259
1410
  };
1260
1411
  }
@@ -1267,7 +1418,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1267
1418
  const serverTools = [];
1268
1419
  const serverAxes = [];
1269
1420
  const dropped = [];
1270
- const mintedNames = new Set();
1421
+ const mintedNames = new Map();
1271
1422
  for (const t of listed.tools) {
1272
1423
  if (spec.allowTools && !spec.allowTools.includes(t.name)) {
1273
1424
  continue;
@@ -1290,14 +1441,17 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1290
1441
  }
1291
1442
  const remoteName = t.name;
1292
1443
  const namespacedName = mintNamespacedToolName(MCP_NAMESPACE, spec.name, remoteName);
1293
- if (mintedNames.has(namespacedName)) {
1444
+ const mintedBy = mintedNames.get(namespacedName);
1445
+ if (mintedBy !== undefined) {
1294
1446
  dropped.push({
1295
1447
  tool: inlineUntrusted(t.name),
1296
- reason: `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`,
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`,
1297
1451
  });
1298
1452
  continue;
1299
1453
  }
1300
- mintedNames.add(namespacedName);
1454
+ mintedNames.set(namespacedName, t.name);
1301
1455
  const hintAxis = mcpAxisFor(namespacedName, t.annotations);
1302
1456
  const axis = applyCallerAxisOverride(namespacedName, hintAxis, spec.toolAxes?.[remoteName]);
1303
1457
  if (axis)
@@ -1417,3 +1571,8 @@ function asServerWarning(spec, err) {
1417
1571
  warning.code = "mcp.server_unavailable";
1418
1572
  return warning;
1419
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
+ }