iit-mcp-hudu 0.5.0 → 0.6.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/.env.example CHANGED
@@ -106,6 +106,16 @@ LOG_LEVEL=info
106
106
  # hudu_read; the dedicated per-resource resolve tools are unaffected.
107
107
  # HUDU_SEARCH_RESOURCES=articles,assets,companies,users,groups,websites
108
108
 
109
+ # Which body-index tier hudu_search uses: auto (answer from Hudu search now, build the body index in
110
+ # the background), index (a call may wait for a complete body-index answer) or vendor (never build a
111
+ # body index). Unset means the transport default: auto under http, index under stdio. Under http the
112
+ # tier is policy, not a call parameter, so the served schema offers neither tier nor refresh.
113
+ # HUDU_SEARCH_TIER_POLICY=auto
114
+
115
+ # Body-index TTL in milliseconds (the SDK's indexTtlMs; default 600000, ten minutes). Beyond it, an
116
+ # answer is marked stale and the next search rebuilds in the background.
117
+ # HUDU_SEARCH_INDEX_TTL=600000
118
+
109
119
  # Image tag docker-compose.yml builds and runs as. scripts/deploy.sh sets it to the git short SHA
110
120
  # of the deployed commit, so the running container is always tied to a commit and the previous
111
121
  # tag stays resolvable for rollback. Do not deploy by hand with it unset, and never tag :latest.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.0
4
+
5
+ **Upgrading from 0.5.1:** under `MCP_TRANSPORT=http` the served `hudu_search` schema no longer offers `tier` or `refresh`, and the tier is server-side policy: `auto` by default, so a call answers from the vendor tier while the index builds. Set `HUDU_SEARCH_TIER_POLICY=index` for an http deployment that should wait. The body-index text budget and document cap are now derived from the process heap (a sixteenth of the V8 ceiling) instead of the SDK's 64 MiB / 50,000 defaults; no configuration change is needed. Under `stdio` the default tier stays `index` and both fields are still served.
6
+
7
+ - Fix: `hudu_search` no longer blocks a caller on a cold body-index walk under `MCP_TRANSPORT=http` by default (#49): the default policy there is `auto`, which answers from the vendor tier while the index builds. `HUDU_SEARCH_TIER_POLICY=index` opts an http deployment back into that wait, by design. A raw `hudu_read` call that still carries `tier`/`refresh` is answered by policy and says so: `meta.policyOverride` records `{ stripped, appliedTier }` and the response text names the override. The served schema there omits `tier` and `refresh` (a request that still sends either is served by the policy path), the call answers from the vendor tier while the index builds in the background, and `meta.index` / `meta.degraded` report the index state and any body-blind answer. Under `http` the raw `hudu_read` path to `operations.searchKnowledge` follows the same policy; under `stdio` a raw call that omits `tier` keeps the SDK's `auto` default, while `hudu_search` defaults to `index`. New `HUDU_SEARCH_TIER_POLICY=auto|index|vendor` (default `auto` under http, `index` under stdio, `vendor` when `HUDU_SEARCH_RESOURCES` excludes the corpus) and `HUDU_SEARCH_INDEX_TTL` (milliseconds, the SDK's index TTL; default 600000). Under `http` with `HUDU_SEARCH_TIER_POLICY=index` the tool description now says the tier is server-side policy even when the search set is the full corpus (it used to fall back to the SDK's caller-choice sentence there). `stdio` behaviour is unchanged.
8
+ - Fix: the body-index text budget is now sized to the process heap (`maxIndexTextBytes` = a sixteenth of the V8 heap ceiling, clamped to 8-64 MiB), so a 512 MiB container reports index state `partial` instead of dying with `Reached heap limit` and restarting mid-search (#51). The budget is derived per process, not configurable by env, and it is a POOL for the process: each scoped client in the http cache receives `floor(pool / 8)` (whole MiB, never below 1 MiB), so eight warm credentials cannot each claim the whole text budget and multiply it by the cache size. The sizing is an ESTIMATE, not a proof. The ~90 MB baseline heap and about 4 bytes of index structures per byte of text ACTUALLY HELD come from the #51 crash trace (a 252 MB peak on the production tenant), and that measurement IS a 512 MiB container running the SDK-default 64 MiB budget: the index held ~41 MB of that budget, and ~90 + 4 x 41 = ~254 MB reconciles with the measured peak, whereas the same ratio read against the 64 MiB budget would predict ~346 MB, which the trace never saw. MEASURED: a standalone walk of a synthetic 25,000-asset / 3,300-article tenant through the real SDK client under a ~262 MB V8 ceiling (an uncommitted one-off probe under `node --max-old-space-size=259 --max-semi-space-size=1`) peaked at ~136 MiB with this budget (25 MiB pool, 25,600 docs) and ~147 MiB with the OLD 64 MiB / 50,000 settings; both survived. MEASURED END TO END BEFORE THE CHANGE (round 10): `dist/main.js` under `node --max-old-space-size=259 --max-semi-space-size=1` peaked at ~188 MB heapUsed (~69% of the 259 MiB ceiling) while building the 25,000-asset / 3,300-article index over stdio; it survived (exit 0, 12.0 s), walked 28,300 documents and retained 26,624 with index state `partial`. bodiesEvicted was 0, so that run did not exercise text eviction. Measured structures were 187.96 - 24.6 = ~163 MB; against a ~90 MB production baseline that extrapolates to ~253 MB, ~98% of 259 MiB and no usable margin. The stub corpus is synthetic and its bodies and baseline heap are smaller than production's. That is a LOWER BOUND for production: the honest round-10 extrapolation was ~253 MB (~98% of 259 MiB). MEASURED END TO END AFTER THE CHANGE (round 11, one sixteenth): the same SHAPE of probe peaked at 118.51 MB against a 27.57 MB base, so structures cost ~90.93 MB; it retained 16,384 documents (articles 3,300, assets 13,084), reported state `partial`, and bodiesEvicted was 0. The extrapolated production peak is ~90.93 + 90 = ~180.93 MB, ~70% of 259 MiB and ~26 MB below the 207 MB (80%) target. The probe script is not committed and the stub was recreated for this round, so the delta also reflects stub differences; the production figure remains an extrapolation. The claim remains that the budget bites and reports `partial`, not that a build cannot exhaust the heap. The text budget alone bounds index TEXT, not the index: a document whose text it evicted keeps its record and postings, the SDK's own default is 50,000 documents, and a walk can reach 250 pages x 100 rows x 2 body resources. So the same heap-derived pool now also sizes `maxDocs` (one document per KiB of text budget, clamped to 1,500-50,000): 512 MiB -> 2 MiB of text and 2,048 documents per scoped client (16,384 for the stdio pool), 1 GiB container's ~816 MB V8 ceiling (an ASSUMED ~80% container-to-heap ratio; the MEASURED 512 MiB ratio is ~50.6%, 259 MB) -> 6 MiB and 6,144 per client (50,000 for the pool), and a 1024 MB ceiling -> 8 MiB and 8,192. The ~1 KB of record plus postings per document is an ESTIMATE, so the pair bounds growth without proving a peak; a document past the cap is not RETAINED (the freshest are kept and the index reports index state `partial`, as it already did for the text budget). The SDK upserts every walked document and applies the cap only AFTER the walk, so `maxDocs` bounds the retained index, not the build-time peak; the peak during a walk is bounded by the heap ceiling and the text budget's eviction. All ~3,300 tenant articles fit at the 1 GiB figure, Which documents a small heap's cap truncates first is INFERRED, not measured: title-only assets are the expected first loss. The end-to-end server run kept 16,384 of 28,300 documents and did not observe the order. The trade is explicit: a deployment holding several credentials builds shallower indexes; a deployment needing depth should raise the container memory or serve over stdio, where one long-lived credential owns the whole pool.
9
+ - MEASURED (standalone probe, not committed): the body-index peak under the ROUND-10 tenth settings is ~136 MiB on a synthetic 25,000-asset / 3,300-article tenant at a ~262 MB V8 ceiling (an uncommitted one-off probe under `node --max-old-space-size=259 --max-semi-space-size=1`); the OLD 64 MiB / 50,000 settings peaked at ~147 MiB and also survived. This is a LOWER BOUND for production: the honest round-10 extrapolation was ~253 MB (~98% of 259 MiB), and the round-11 sixteenth measured 118.51 MB peak against a 27.57 MB base (90.93 MB structures), extrapolating to ~181 MB (~70%). Issue #51's trace remains the only production measurement (~252 MB peak on the old 64 MiB budget). The `maxDocs` review that used to sit in this item is done: `search.maxDocs` is now capped from the same pool (see above).
10
+ - Practice: `hudu_search` reports progress while it WAITS on a body-index build (#52). With a `_meta.progressToken` on the call, a stdio call that will wait (an explicit `tier: "index"`, or `refresh: true`) sends `notifications/progress` after 2 s and then at most every 7 s (the cadence is held below #52's 10 s bound, since `setInterval` never fires early), carrying elapsed whole seconds only - the walk's page count is not observable through the SDK, so no `total` and no percentage is sent. The wait, the ticks and the cancellation are transport-independent: `http` with `HUDU_SEARCH_TIER_POLICY=index` waits too. A cancelled request (`ctx.mcpReq.signal`) stops waiting and answers the honest degraded result (`meta.degraded.reason: "body-not-indexed"` with `meta.cancelled: { during: "index-build", waited }`; `meta.reasons` stays empty because no bound bit) instead of throwing; a rejected or throwing notification never fails the call. The cancelled answer's advice is transport-aware: under `stdio` it still offers `tier:"vendor"`, under `http` it says to retry because the body index keeps building in the background (no tier field is served there). Its `meta.index` omits the SDK's optional `builtAt` / `ageMs` entirely - no generation was scored, so neither is published - while `docs` stays the required empty record, meaning "no generation recorded", never "zero documents". Only the waiting paths tick - `auto` answers at once and `vendor` never touches the index. The raw invoke path (`hudu_read` of `operations.searchKnowledge`) gets the same ticks and the same cancelled answer, and a request already cancelled when the call arrives starts no walk at all (its answer reports index state `cold`, meaning no generation was observed by that call — a warm stdio index is possible and the answer does not claim otherwise).
11
+ - Practice: the served `tools/list` surface is 27,470 B (write profile, 19 tools) / 22,742 B (read-only, 17 tools) in the default configuration, instead of 39,444 / 33,899 (#52), and `hudu_search` alone is 2,626 B instead of 5,121. The largest figures across the supported tier policies are 27,511 / 22,783 / 2,667 B (stdio with `HUDU_SEARCH_TIER_POLICY=vendor`). Each binding ceiling now keeps at least 200 B free (217 B read-only, 233 B `hudu_search` in that configuration; the contract's split is 23,000 B read-only / 28,000 B write-enabled), and the gate asserts that margin explicitly so the next edit fails with a plain message instead of a one-byte miss. This round also moved the last low-value clauses to the on-demand surfaces: `hudu_search`'s resource default (the enum lists it) and its `min_score`/`topic` explanations (help topic `query`), the `asset_layouts` page detail (its operation contract), and the `hudu_list` "prefer this over" hint; `hudu_resolve_any`'s `resources` field gained back its enumerated default and the pre-request refusal rule. Every tool's description is now server-owned compact prose (`src/tools/tool-prose.ts`): what the tool does and what happens when a bound bites, once; the repeated paragraphs (bounds, credential refusal, article format, filter lists) moved to the surfaces that exist for them (`hudu_describe_operation`, `hudu_list_operations`, `hudu_search` mode:"help"). Machine-enforced semantics (modes, refusal codes, limits, tier defaults per transport) are unchanged. The ONE served-schema difference under `http` is that `tier`/`refresh` are omitted and the input object is loose (`additionalProperties: {}`), so a client-sent value still reaches the handler and is reported as an override (`meta.policyOverride`, named in the text) instead of being rejected at parse time as under `stdio`. The heap-derived index budget and document cap are new on BOTH transports and replace the SDK's 64 MiB / 50,000 defaults. `test/tools/surface-size.test.ts` now measures `{default, auto, index, vendor} x {stdio, http}` and fails if a write profile exceeds 28,000 B, a read-only profile 23,000 B, or `hudu_search` 2,900 B in any of them, or if a read-only or `hudu_search` measurement leaves less than 200 B of headroom.
12
+
13
+ - Docs: the `hudu_search` input object is deliberately LOOSE over `http` only. `tier` and `refresh` are not served there, and the looseness is what lets a caller that still sends one reach the policy path and be told so (`meta.policyOverride` names the stripped fields and the applied tier). Tightening the http object to `.strict()` would reject the field before the handler could report the override and break that documented path; `stdio` serves both fields and stays STRICT, so an unknown argument there is refused rather than silently ignored. A code comment in `src/tools/search.ts` says so.
14
+ - Docs: `refresh` is stated honestly in the stdio field description and in help mode: it rebuilds THIS SERVER'S local body index, changes nothing in Hudu, can take minutes and is stdio-only. `hudu_search` keeps `readOnlyHint: true` because the hint describes the target system, which a rebuild never writes.
15
+ - Docs: the Mermaid form written by the Hudu UI editor is now verified. It stores `<pre class="language-mermaid" data-id="…"><code class="language-mermaid">…</code></pre>`, the same shape the SDK writes plus editor `data-id` attributes; a Markdown round trip keeps the diagram text and drops the `data-id` attributes. Replaces the earlier "unverified" caveat.
16
+
17
+ ## 0.5.1
18
+
19
+ **Upgrading from 0.5.0:** no configuration or client changes needed; behaviour only. A record read (get, getSection, fetch_many, article context) after a `hudu_write` / `hudu_delete` in the same server process now returns the written data instead of the cached pre-write record. A write from another process or deployment can still be served stale for up to the cache TTL (caches are per process). The `hudu_search` body index is a separate store that a write does not refresh: after a write, pass `refresh: true` or wait for the index TTL (SDK default 10 min) before relying on a body search to find new text.
20
+
21
+ - Fix: record reads (get, getSection, fetch_many, article context) after a write are fresh in the same process: the server now clears the read client's cache after every write or delete attempt (node-hudu 0.19.0 `clearCache()`); previously a read after a write could return the pre-write record for up to the cache TTL (5 min for articles). A dry run and a policy refusal do not clear. The `hudu_search` body index is not refreshed by a write (`refresh: true`, or the 10 min index TTL) (#45, refs #25).
22
+ - Dependency: node-hudu 0.18.0 -> 0.19.0 (exact pin). Plan hash unchanged (`9d110798...`); operation set unchanged (236).
23
+ - Docs: correction to the 0.3.0 line. `articles.resolve`, `articles.getContext` and `hudu_get_article_context` return an article summary with no body by default, so they set no `markdown_lossy` / `markdown_lossy_codes`; only `opts.expand: true` returns a Markdown body that can carry the signal. README and architecture note updated.
24
+ - Docs: Mermaid stored form. Observed live, a ```mermaid fence is stored as `<pre class="language-mermaid"><code class="language-mermaid">...</code></pre>` and round-trips losslessly through `articles.get` and `patchSection`; the read path accepts both that and the bare `<pre>` form. A save made in the Hudu UI editor is not yet verified.
25
+ - Docs: README notes that `HUDU_READ_ONLY=true` makes `hudu_write` / `hudu_delete` a JSON-RPC `-32602` "tool not found", and that `asset_passwords.deleteShare` of a missing share returns 404 `NOT_FOUND` every time; SCOPING notes the live stdio `tools/list` sizes (39,436 / 33,891 B); the per-user-identity decision record gains the 2026-10-25 sandbox data points.
4
26
 
5
27
  ## 0.5.0
6
28
 
package/README.md CHANGED
@@ -31,7 +31,7 @@ four old names. Rows are compact summaries by default (id, name, key scalar fiel
31
31
  25-row assets page under 20,000 characters instead of ~88,000; pass `expand: true` for the full
32
32
  records (`hudu_read` `articles.list` rows are always full). Article rows carry no body unless expanded. Paging is unchanged (`asset_layouts` still takes no `page_size`).
33
33
 
34
- These tools run on node-hudu 0.18.0; the `SDK behaviour (0.12.0)` section below covers the behaviours that shape them: redirect refusal, the closed input contract, the error envelope, and the client opt-ins. Numeric ids must be positive integers on every tool; anything else is refused before a request is made.
34
+ These tools run on node-hudu 0.19.0; the `SDK behaviour (0.12.0)` section below covers the behaviours that shape them: redirect refusal, the closed input contract, the error envelope, and the client opt-ins. Numeric ids must be positive integers on every tool; anything else is refused before a request is made.
35
35
 
36
36
  `hudu_fetch_many` (node-hudu 0.12.0) fetches up to 20 single records across mixed resources in
37
37
  one call: each item is exactly `{ resource, id, fields? }` — one of the 22 bare-id resources, a
@@ -196,6 +196,8 @@ Discovery reports deployment-policy refusals as unreachable.
196
196
  `HUDU_READ_ONLY=true` is a hard override of the write policy: no `hudu_write` / `hudu_delete` tool is
197
197
  registered and every mutation is refused, whatever `HUDU_WRITE_POLICY` and `HUDU_WRITE_ALLOW` say. Unset or
198
198
  `false` leaves `HUDU_WRITE_POLICY` in charge (default `deny`). The effective policy is logged once at boot.
199
+ Because the two tools are not registered, a client that calls them gets the protocol-level JSON-RPC error
200
+ `-32602` (`Tool hudu_write not found`), not a tool error envelope.
199
201
 
200
202
  `HUDU_WRITE_POLICY` stays a **deployment-wide** decision, not a per-caller one. Per-caller
201
203
  credentials make each caller's Hudu role an external authority too, but that authority is not ours
@@ -229,6 +231,9 @@ them. Under `allow`, `listShares` / `getShare` work and the share `url` (a beare
229
231
  `createShare` is never runnable here: the SDK refuses it (`POLICY_DENIED`, `reveal_required`, dry-run
230
232
  included, no request) without its `reveal` capability, this server never grants `reveal`, and
231
233
  `hudu_list_operations` marks the row unreachable. Link-shaped text is scrubbed from share errors.
234
+ Observed live, `deleteShare` of a share that does not exist returns `404 NOT_FOUND` on every attempt, repeats included.
235
+ This server never creates a share (`createShare` is unreachable), but under `HUDU_SECRET_READS=allow` and a permitting
236
+ write policy `deleteShare` will delete any existing share, including one created in the Hudu UI.
232
237
 
233
238
  `hudu_get_company_context` accepts `opts.skip` (`assets`, `articles`, `websites`, `asset_passwords`); pass
234
239
  `["asset_passwords"]` when the key cannot read credentials. The skipped list comes back empty with
@@ -256,11 +261,99 @@ the schema never advertises a guaranteed-empty read. A per-resource read failure
256
261
  thrown by the resolve fan-out, are reported as unavailable — never with Hudu's own authorization
257
262
  message, which is a fact about the calling credential, not the caller's query.
258
263
 
259
- Under `http` each caller's search scope starts cold, so `hudu_search` defaults to `tier:"auto"`
260
- rather than `"index"`: it answers from Hudu search immediately and builds the body index in the
261
- background, so a caller's later search is body-aware without the first one awaiting a corpus-wide
262
- walk (some clients enforce a 60s call timeout). Under `stdio` the one long-lived index stays warm and the
263
- default is `"index"`. Pass `tier:"index"` explicitly for a complete first answer.
264
+ Under `http` each caller's search scope starts cold, so `hudu_search` uses `tier:"auto"` rather than
265
+ `"index"`: it answers from Hudu search immediately and builds the body index in the background, so a
266
+ caller's later search is body-aware without the first one awaiting a corpus-wide walk. The tier
267
+ decides whether a call may *wait*, and the calling model can observe neither the index's warmth nor
268
+ the walk's duration, so under `http` the tier is deployment policy, not a call parameter: the served
269
+ schema offers neither `tier` nor `refresh`, and a request that still sends either is answered by the
270
+ policy path, which records the stripped fields in `meta.policyOverride`. That path returns
271
+ immediately only when the resolved policy is `auto` or `vendor`:
272
+ with `HUDU_SEARCH_TIER_POLICY=index` an `http` call still waits for a complete body-index answer,
273
+ which is exactly that knob's documented purpose. `meta.index` reports the index state (`state`: `cold`,
274
+ `refreshing`, `partial`, `warm`), and `meta.degraded` says when the answer was body-blind. Under
275
+ `stdio` the one long-lived index stays warm, both fields stay in the schema, and the default is
276
+ `"index"`; a caller there can still pass `tier:"index"` for a complete first answer.
277
+
278
+ `HUDU_SEARCH_TIER_POLICY=auto|index|vendor` sets that policy explicitly (default `auto` under
279
+ `http`, `index` under `stdio`): `vendor` forces the vendor tier and never builds a body index,
280
+ `index` lets a call wait for a complete body-index answer. When `HUDU_SEARCH_RESOURCES` excludes
281
+ articles or assets the policy is `vendor` and no index path runs, whatever the variable says.
282
+ `HUDU_SEARCH_INDEX_TTL` (milliseconds, default `600000`) is the body index's freshness window. What
283
+ the SDK does past it — answer from the stale index while rebuilding in the background — is
284
+ UNVERIFIED here: this repo sets the knob (a test asserts it reaches the client) but has not measured
285
+ the stale-rebuild behaviour.
286
+
287
+ A request cancelled while a call waits on a body-index build answers instead of throwing.
288
+ `meta.degraded.reason` stays `body-not-indexed` (the SDK's closed union), `meta.cancelled` records
289
+ `{ during: "index-build", waited }` as a deliberate SERVER extension (the SDK's meta has no
290
+ cancellation field; it travels in the open `meta` object beside the SDK vocabulary, and
291
+ `meta.policyOverride` is the same kind of extension on the http path), and `meta.reasons` stays empty because no bound bit — the answer
292
+ was cancelled, not bounded. Its `meta.index.state` is `cold` or `refreshing`; `cold` here means no
293
+ generation was observed by THIS call, not that the process has no warm index (under `stdio` a warm
294
+ index is possible, and this answer does not claim otherwise). The served `hudu_search` outputSchema
295
+ types `meta` as an open object on purpose (a closed union costs about 1.1 KB against ~233 B of
296
+ surface headroom); the meta vocabulary is enforced by the tests that publish it
297
+ (`test/tools/search-progress.test.ts`), not by the outputSchema.
298
+
299
+ The body index is bounded by TWO caps derived from the same heap estimate: a text budget and a
300
+ document cap. The text budget is a SIXTEENTH of the process's V8 heap ceiling, clamped to 8-64 MiB;
301
+ the document cap is one document per KiB of that budget (`maxDocs`), clamped to 1,500-50,000. Both
302
+ travel the SDK's `search` options, so neither is an env knob. The divisor moved from 10 to 16 in
303
+ round 11 because the tenth left no usable production margin (the arithmetic below).
304
+
305
+ Where the sixteenth comes from, and what the "4x" is a ratio of. The #51 crash trace is a 512 MiB
306
+ container running the OLD behaviour — the SDK-default 64 MiB text budget — which peaked at ~252 MB
307
+ of heap and died with `Reached heap limit` mid-search. It measured the ~90 MB baseline heap and about
308
+ 4 bytes of index structures per byte of text ACTUALLY HELD, not per byte of budget: that index held
309
+ ~41 MB of text out of its 64 MiB budget, and the peak reconciles as ~90 + 4 x 41 = ~254 MB. Read
310
+ against the budget instead of the held text the same ratio would predict 90 + 4 x 64 = ~346 MB, which
311
+ the trace never saw. A 512 MiB container (roughly a 259 MB heap ceiling) now gets a 16 MiB pool and
312
+ 2 MiB per scoped client. A standalone walk of a synthetic 25,000-asset / 3,300-article tenant through
313
+ the real SDK client under a ~262 MB V8 ceiling (an uncommitted one-off probe; `--max-semi-space-size=1`
314
+ brings V8's reported limit to ~262 MB, while `--max-old-space-size=259` alone reports ~451 MB in
315
+ Node 24) peaked at ~136 MiB with the ROUND-10 tenth settings; the same walk with the OLD
316
+ 64 MiB / 50,000 settings peaked at ~147 MiB. Both survived. That probe's baseline is lower than the
317
+ server's ~90 MB. A 1 GiB container's ~816 MB V8 ceiling — an ASSUMED ~80% container-to-heap ratio; the MEASURED ratio for the 512 MiB container is ~50.6% (259 MB) — gets a 51 MiB pool and 6 MiB per scoped client; a 1024 MB heap ceiling is what reaches the 64 MiB cap.
318
+
319
+ The document cap exists because the text budget bounds index TEXT only: a document whose text was
320
+ evicted keeps its record and postings, the SDK's own default is 50,000 documents, and a walk can
321
+ reach 250 pages x 100 rows x 2 body resources. Pairing the caps bounds that overhead — a 512 MiB
322
+ container indexes up to 2,048 documents per scoped client (16,384 for the stdio pool), a 1 GiB
323
+ container's ~816 MB ceiling (assumed ~80%) 6,144 per client (50,000 for the pool), and only a 1024 MB ceiling 8,192. The per-document cost is an ESTIMATE (~1 KB of record
324
+ plus postings per document, on top of the text ratio), so the pair bounds growth without proving a
325
+ peak. HONEST SCOPE: the SDK upserts every walked document and calls its cap only AFTER the walk, so
326
+ `maxDocs` bounds the RETAINED index, not the build-time peak; the peak during a walk is bounded by
327
+ the heap ceiling and the text budget's eviction, not by `maxDocs`. A capped index reports index state
328
+ `partial`, exactly as it already does when the text budget bites. All ~3,300 articles of the
329
+ production tenant fit at the 1 GiB figure; which documents the cap truncates first on a small heap is
330
+ INFERRED, not measured: title-only assets are the expected first loss, because they cost a record
331
+ without carrying text. The measured end-to-end walk kept 16,384 of 28,300 documents and never
332
+ observed which ones went first.
333
+
334
+ BEFORE THE DIVISOR CHANGE. The round-10 end-to-end run measured the real server (`dist/main.js`) under
335
+ a 259 MiB V8 ceiling: peak heapUsed 187.96 MB with a 24.6 MB baseline, so the INDEX STRUCTURES cost
336
+ ~163 MB. Production's baseline is ~90 MB, so the honest production peak was 163 + 90 = ~253 MB
337
+ against 259 MiB — ~98%, about 6 MB of headroom. That leaves no usable margin. The round-10 README
338
+ instead added 90 MB to a standalone-walk figure and reported ~233 MB; that understated the risk.
339
+
340
+ AFTER THE DIVISOR CHANGE (one sixteenth, this round's measurement). The same SHAPE of probe — the
341
+ built server under `node --max-old-space-size=259 --max-semi-space-size=1 --import <uncommitted
342
+ heap-sampler.mjs> dist/main.js`, stdio against a stub tenant of 25,000 assets (250 pages x 100 rows) and 3,300 articles
343
+ with HTML bodies, `hudu_search` with `tier:"index"` and `refresh:true` — peaked at 118.51 MB
344
+ heapUsed against a 27.57 MB base, so the structures cost ~90.93 MB. The process survived (exit 0)
345
+ and the call took 11.62 s. The index retained 16,384 documents (articles 3,300, assets 13,084) and
346
+ reported state `partial`; bodiesEvicted was 0. PRODUCTION EXTRAPOLATION: 90.93 + 90 = ~180.93 MB,
347
+ ~70% of the 259 MiB ceiling and ~26 MB below the 207 MB (80%) target. MEASURED PROBE PEAK: 118.51 MB,
348
+ ~45.8% of the 259 MiB ceiling. The target is met. The fraction shipped is one sixteenth. The
349
+ probe scripts were removed from the tree after measuring (the review asked for no ad-hoc probes in
350
+ the repo), and the stub was recreated for this round, so the before/after delta also reflects stub
351
+ differences and is not a controlled A/B;
352
+ the production extrapolation uses this round's measured structures. The stub corpus is synthetic,
353
+ its bodies are smaller than production's, and its ~27.6 MB baseline is lower than the ~90 MB
354
+ production baseline, so the production figure is an extrapolation. bodiesEvicted 0 means this run
355
+ did not exercise eviction. Issue #51's trace remains the only PRODUCTION measurement (~252 MB peak
356
+ on the old 64 MiB profile).
264
357
 
265
358
  The read-through cache (SDK `recommended` preset), the 429 cooldown gate and the 30 s whole-call deadline are fixed, not env-tunable.
266
359
  `blockPrivateHosts` deliberately gets no override: it is the literal-IP SSRF floor for
@@ -269,9 +362,9 @@ caller-named origins — a security property, not a deployment preference.
269
362
  ## Article bodies are Markdown (node-hudu 0.14+)
270
363
 
271
364
  `hudu_read` and `hudu_write` pass `contentFormat: 'markdown'` to the SDK, and `hudu_get_article_context`
272
- asks for Markdown, so single-article bodies (`articles.get`, `articles.getContext`, `articles.resolve`,
273
- `create`, `update`) are returned and accepted as Markdown. Hudu callouts (`> [!INFO]`), accordions, task
274
- lists and Mermaid fences (stored as the editor-native `<pre class="language-mermaid">` form) round-trip as the dialect documents. Rows from `hudu_fetch_many`, `hudu_read` `articles.list`, `articles.search` and `articles.findBySlug` still carry stored HTML:
365
+ asks for Markdown, so single-article bodies (`articles.get`, `create`, `update`, and
366
+ `articles.resolve` / `articles.getContext` only with `opts.expand: true`) are returned and accepted as Markdown. Hudu callouts (`> [!INFO]`), accordions, task
367
+ lists and Mermaid fences round-trip as the dialect documents. Observed live (Hudu 2.48.1), a ```` ```mermaid ```` fence is stored as `<pre class="language-mermaid"><code class="language-mermaid">...</code></pre>` (entity-escaped, indentation preserved) and reads back as the identical fence, including through `articles.patchSection`. The read path matches the `<pre>` by class and takes its text, so it accepts both that form and the bare `<pre class="language-mermaid">` form. That is also what the Hudu editor itself stores (checked on 2026-10-25 against a diagram saved in the UI editor): the same `<pre class="language-mermaid"><code class="language-mermaid">` element pair, plus a `data-id` attribute on the `pre` (and on other blocks such as `<p>`). A Markdown round trip drops those editor `data-id` attributes and keeps the diagram text unchanged; it does not use the `code-block-mermaid-source` class. Rows from `hudu_fetch_many`, `hudu_read` `articles.list`, `articles.search` and `articles.findBySlug` still carry stored HTML:
275
368
  those operations take no format. Rules a caller needs:
276
369
 
277
370
  - A write's `data.content` is parsed as Markdown unless it opens with block-level HTML (`<p>`, `<div>`,
@@ -284,8 +377,7 @@ those operations take no format. Rules a caller needs:
284
377
  `articles.getSection` carries the same `markdown_lossy` / `markdown_lossy_codes` signal as `articles.get`;
285
378
  it describes the whole article, so the loss may be in another section.
286
379
  - A Markdown read can be incomplete: anything the dialect cannot express is dropped or reduced
287
- (`<video>` and `<script>` are dropped entirely). `hudu_read` (`articles.get` / `getContext` / `getSection` / `resolve`) and
288
- `hudu_get_article_context` then set `markdown_lossy: true` (with `markdown_lossy_codes`, the SDK's
380
+ (`<video>` and `<script>` are dropped entirely). `hudu_read` (`articles.get` / `getSection`, and `resolve` / `getContext` with `opts.expand: true`) and `hudu_get_article_context` with `opts.expand: true` then set `markdown_lossy: true` (with `markdown_lossy_codes`, the SDK's
289
381
  `diffArticleRoundTrip` content-loss codes) and add one sentence to the result text; fetch the stored
290
382
  HTML with `hudu_fetch_many` (`{items:[{resource:"articles", id}]}`) to see the full body. The read is one
291
383
  fetch: the SDK converts the body and reports the loss on the record (no second request); a caller's own
@@ -294,6 +386,10 @@ those operations take no format. Rules a caller needs:
294
386
  A body that converts to nothing (only `<video>`, `<input>`, `<p></p>`) reads as `content: ""` with
295
387
  `markdown_lossy_codes` containing `ROUNDTRIP_BODY_EMPTY` (it used to fail with `CONVERSION_EMPTY_OUTPUT`).
296
388
  The SDK fails a Markdown read with `CONFIG_ERROR` when the Markdown form exceeds 256 KiB.
389
+ - `articles.resolve`, `articles.getContext` and `hudu_get_article_context` carry no lossy signal in the default mode: the result
390
+ holds an article summary (no body), so there is nothing to convert. Only with `opts.expand: true` is the full record
391
+ returned (for getContext, as `article`) with a Markdown body, and then the same `markdown_lossy` /
392
+ `markdown_lossy_codes` signal applies.
297
393
 
298
394
  ## SDK behaviour (0.12.0)
299
395
 
@@ -334,9 +430,11 @@ Every client the server builds — the parent, the fail-closed placeholders and
334
430
  blindly.
335
431
  - `cache: 'recommended'` — fixed on: the
336
432
  per-entity read-through TTL preset (1 h structural / 5 min mutable / 1 min base). Caches are
337
- per client, so per scope. A write through the write or delete client does not clear the read
338
- client's cache (separate instances), so a read after a mutation is TTL-bounded stale, never
339
- unbounded.
433
+ per client, so per scope. The write and delete clients have their own caches, so after every `hudu_write` /
434
+ `hudu_delete` attempt that is not a dry run (including a failed or timed-out one, which may have
435
+ applied) the server clears the read client's cache for the same scope: a record read (get, getSection, fetch_many, article context) after a write in
436
+ the same server process is fresh. A write made by another process or deployment can still be served
437
+ stale for up to the TTL, because caches are per process. The `hudu_search` body index is a separate store that a write does not refresh: after a write, pass `refresh: true` or wait for the index TTL (SDK default 10 min) before relying on a body search to find new text.
340
438
  - `blockPrivateHosts` on the per-scope clients: the literal-IP SSRF floor for CALLER-named
341
439
  origins, fail-closed at client construction. It complements the `HUDU_ALLOWED_BASE_HOSTS`
342
440
  hostname matcher (which alone can express wildcard suffixes) and holds even in
@@ -371,7 +469,7 @@ pnpm run dev
371
469
 
372
470
  CI (`.github/workflows/ci.yml`) runs `typecheck`, `lint` and `test` on every push and pull request.
373
471
 
374
- `node-hudu` is pinned to `0.18.0`. The catalog hash test detects metadata drift; upgrades require
472
+ `node-hudu` is pinned to `0.19.0`. The catalog hash test detects metadata drift; upgrades require
375
473
  review of the client authority, operation enums and redaction regressions. Docker uses `pnpm install --frozen-lockfile`.
376
474
 
377
475
  ## Deployment
package/dist/config.js CHANGED
@@ -33,6 +33,18 @@ export const WRITE_POLICIES = ['deny', 'allow_list', 'all'];
33
33
  * cannot hand a secret to a model, never one that can.
34
34
  */
35
35
  export const SECRET_READ_POLICIES = ['deny', 'allow'];
36
+ /**
37
+ * The deployment's authority over the body-index tier `hudu_search` uses.
38
+ *
39
+ * The tier decides whether a call may WAIT for a corpus-wide index walk. That is a budget question
40
+ * the calling model cannot observe — it knows neither the index's warmth nor the walk's duration —
41
+ * so it is deployment policy, not a request parameter: `auto` answers from Hudu search now and
42
+ * builds the body index in the background, `index` may wait for a complete body-index answer, and
43
+ * `vendor` never builds an index at all. Unset means the transport default: `index` under `stdio`
44
+ * (one long-lived credential, one warmed index) and `auto` under `http` (every caller's scope
45
+ * starts cold, and a cold walk can outlast the consumer's call timeout).
46
+ */
47
+ export const SEARCH_TIER_POLICIES = ['auto', 'index', 'vendor'];
36
48
  /** Parse `HUDU_WRITE_ALLOW` once, here, so every consumer reads the same list. */
37
49
  function parseAllowList(raw) {
38
50
  return (raw ?? '')
@@ -147,6 +159,18 @@ const schema = z
147
159
  * call weeks later. Applies to `hudu_search`, `hudu_resolve_any` and the same operations through `hudu_read`; the dedicated per-resource resolve tools are unaffected.
148
160
  */
149
161
  HUDU_SEARCH_RESOURCES: z.string().optional().transform(parseSearchResources),
162
+ /**
163
+ * The tier `hudu_search` uses when the caller names none — see {@link SEARCH_TIER_POLICIES}.
164
+ * Unset keeps the transport default (`index` under stdio, `auto` under http); `vendor` is
165
+ * forced when `HUDU_SEARCH_RESOURCES` excludes the corpus the index walks.
166
+ */
167
+ HUDU_SEARCH_TIER_POLICY: z.enum(SEARCH_TIER_POLICIES).optional(),
168
+ /**
169
+ * The in-memory body index's TTL in milliseconds (the SDK's `search.indexTtlMs`, default
170
+ * 10 minutes). After it, an answer is marked stale and the next search rebuilds in the
171
+ * background; a shorter value trades vendor-tier reads for fresher bodies.
172
+ */
173
+ HUDU_SEARCH_INDEX_TTL: z.coerce.number().int().positive().optional(),
150
174
  /**
151
175
  * The single switch for mutation. `deny` (the default) registers no `hudu_write` / `hudu_delete`
152
176
  * tool at all; `allow_list` and `all` register them and gate every call. Read effects are never
package/dist/main.js CHANGED
@@ -87,6 +87,12 @@ function main() {
87
87
  }
88
88
  const log = createLogger(config.LOG_LEVEL);
89
89
  logEffectiveWritePolicy(config, log, process.env);
90
+ // `HUDU_SEARCH_TIER_POLICY=index` under `http` re-introduces the #49 blocking wait on purpose:
91
+ // every caller's scope starts cold, so each first call can wait on a corpus-wide body-index walk.
92
+ // Make the trade visible to the operator who set it; never fail the boot over it.
93
+ if (config.MCP_TRANSPORT === 'http' && config.HUDU_SEARCH_TIER_POLICY === 'index') {
94
+ log.warn('HUDU_SEARCH_TIER_POLICY=index over http: a call may block on a corpus-wide body-index walk (the #49 wait). HUDU_SEARCH_TIER_POLICY=auto answers immediately and warms the index in the background; vendor never builds one.');
95
+ }
90
96
  if (config.MCP_TRANSPORT === 'http'
91
97
  && config.HUDU_BASE_URL === undefined
92
98
  && config.HUDU_ALLOWED_BASE_HOSTS === undefined) {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * search-budget.ts — the body index's text budget, sized to the process's own heap ceiling.
3
+ */
4
+ const MIB = 1024 * 1024;
5
+ /** 8 MiB: below this the index would answer from almost nothing, so a tiny heap keeps a floor. */
6
+ export const MIN_INDEX_TEXT_BYTES = 8 * MIB;
7
+ /** 64 MiB: the SDK's own default text bound, and the most a single scope should ever hold. */
8
+ export const MAX_INDEX_TEXT_BYTES = 64 * MIB;
9
+ /**
10
+ * How many scoped clients share the pooled text budget (the cache cap in `tools/server.ts`).
11
+ *
12
+ * The budget below is per PROCESS, and every scoped client materialises its own index, so the pool
13
+ * must be divided by this number rather than handed to each client whole.
14
+ */
15
+ export const SCOPED_INDEX_BUDGET_DIVISOR = 8;
16
+ /**
17
+ * The total extracted text the PROCESS's body indexes may hold, from the V8 heap ceiling in bytes.
18
+ *
19
+ * A SIXTEENTH of the heap, rounded down to whole MiB and clamped to 8-64 MiB. ESTIMATE, not a proof
20
+ * that a build cannot exhaust the heap; 16 because a measured end-to-end run showed a tenth leaves
21
+ * no usable margin.
22
+ *
23
+ * RATIO AND BASELINE: ~4x structure-to-text, per byte of text ACTUALLY HELD (not per byte of budget),
24
+ * and a ~90 MB production baseline — both from issue #51's crash trace, the only PRODUCTION
25
+ * measurement (~252 MB peak on the old 64 MiB budget). That index held ~41 MB of its 64 MiB budget,
26
+ * and 90 + 4 x 41 = ~254 MB reconciles with the measured peak; read against the budget the same
27
+ * ratio would predict ~346 MB, which the trace never saw.
28
+ *
29
+ * MEASURED on the real server under a 259 MiB V8 ceiling. At a tenth (25 MiB pool, 25,600 docs):
30
+ * 187.96 MB peak against a 24.6 MB base, so structures cost ~163 MB; against production's ~90 MB
31
+ * baseline that extrapolates to ~253 MB, ~98% of 259 MiB, no usable margin. At a sixteenth (16 MiB
32
+ * pool, 16,384 docs) the same probe peaked at 118.51 MB against a 27.57 MB base (~90.93 MB of
33
+ * structures). PRODUCTION EXTRAPOLATION: ~180.93 MB, ~70% of 259 MiB and ~26 MB below the 207 MB
34
+ * (80%) target. MEASURED PROBE PEAK: 118.51 MB, ~45.8% of 259 MiB. A standalone walk peaked at ~136
35
+ * MiB with the tenth's pool and ~147 MiB with the OLD 64 MiB / 50,000 settings; both survived.
36
+ * CAVEATS: the stub corpus is SYNTHETIC (smaller bodies and baseline than production), bodiesEvicted
37
+ * was 0 so no run exercised text eviction, the stub was recreated per run so the before/after delta
38
+ * also reflects stub differences, and the production figure is an EXTRAPOLATION from one synthetic
39
+ * walk.
40
+ *
41
+ * POOL FRACTION: the budget is per PROCESS, not per client. Up to
42
+ * {@link SCOPED_INDEX_BUDGET_DIVISOR} scoped clients stay warm and each owns a body index, so each is
43
+ * handed only {@link scopedIndexTextBudgetBytes} (a whole-MiB share). The trade: several credentials
44
+ * build shallower indexes (a scope that hits its share reports index state "partial"); needing depth
45
+ * means raising the container memory or serving over stdio, where one credential owns the whole pool.
46
+ */
47
+ export function indexTextBudgetBytes(heapLimitBytes) {
48
+ const sixteenthInMib = Math.floor(heapLimitBytes / 16 / MIB) * MIB;
49
+ return Math.min(MAX_INDEX_TEXT_BYTES, Math.max(MIN_INDEX_TEXT_BYTES, sixteenthInMib));
50
+ }
51
+ /**
52
+ * The text budget for ONE scoped client: the process pool divided by the scoped-client cap.
53
+ *
54
+ * A whole-MiB share with a 1 MiB floor, never above the pool itself (a pool smaller than the cap
55
+ * would otherwise be oversubscribed). Small pools get a share below the single-client
56
+ * {@link MIN_INDEX_TEXT_BYTES} on purpose — eight clients cannot each hold 8 MiB out of 16 MiB.
57
+ */
58
+ export function scopedIndexTextBudgetBytes(heapLimitBytes) {
59
+ const total = indexTextBudgetBytes(heapLimitBytes);
60
+ const share = Math.floor(total / SCOPED_INDEX_BUDGET_DIVISOR / MIB) * MIB;
61
+ return Math.min(total, Math.max(MIB, share));
62
+ }
63
+ /** 1,500: a doc cap below this would truncate a small tenant's whole corpus, so a tiny budget keeps a floor. */
64
+ export const MIN_INDEX_MAX_DOCS = 1500;
65
+ /** 50,000: the SDK's own default doc count, and the most one index should ever hold. */
66
+ export const MAX_INDEX_MAX_DOCS = 50_000;
67
+ /** ~1 KB of record + postings per indexed document, on top of the ~4x text ratio. ESTIMATE. */
68
+ const INDEX_DOC_BYTES = 1024;
69
+ /*
70
+ * `search.fullRefreshEvery` — the SDK's own full re-walk cadence (default 6 builds) — is NOT wired
71
+ * here, deliberately. The heap goal is met by bounding index SIZE (`maxIndexTextBytes` and
72
+ * `maxDocs`); the cadence decides how OFTEN a full walk runs, not how large the index may grow, so
73
+ * leaving it at the SDK default changes no bound this module exists for. Wiring it would need either
74
+ * a new env knob (out of scope) or a second heap-derived formula with no measurement behind it.
75
+ * Deliberate follow-up: a deployment that needs a different cadence sets it through the SDK.
76
+ */
77
+ /**
78
+ * The document cap for ONE body index, derived from the SAME heap-derived text budget it is paired with.
79
+ *
80
+ * The text budget alone does not bound the index: `maxDocs` defaults to 50,000 in the SDK, a walk can
81
+ * reach 250 pages x 100 rows x 2 body resources, and a document whose text the budget evicted keeps
82
+ * its record and postings, so the record overhead grows even when no text is added. This caps the
83
+ * count from the same pool: ~1 KB of record + postings per document, i.e. one document per KiB of
84
+ * text budget, clamped to 1,500-50,000.
85
+ *
86
+ * ESTIMATE, and it bounds growth rather than proving a peak: the pairing says "this index may hold X
87
+ * MiB of text and Y documents", not "the process cannot exceed its heap". The SDK upserts every
88
+ * walked document and applies the cap only AFTER the walk, so this bounds what the index RETAINS,
89
+ * not the walk's peak (the heap ceiling and the text budget's eviction bound that). A capped index
90
+ * reports index state "partial", as it already does when the text budget bites. Resulting numbers:
91
+ * a 512 MiB container (MEASURED ~50.6% container-to-heap ratio: ~259 MB) gets a 16 MiB pool and 2 MiB
92
+ * per scoped client, so 2,048 documents per scoped client (16,384 for the stdio pool); a 1 GiB
93
+ * container's ~816 MB V8 ceiling — an ASSUMED ~80% ratio, not a measurement — gets a 51 MiB pool and
94
+ * 6 MiB per client, so 6,144 documents (50,000 for the stdio pool), while a 1024 MB ceiling is what
95
+ * reaches the 64 MiB cap and 8,192. All ~3,300 articles of the production tenant fit at the 1 GiB
96
+ * figure. WHICH documents a small heap's
97
+ * cap truncates first is INFERRED, not measured: title-only assets are expected to go first because
98
+ * they cost a record without carrying text, but at 512 MiB the 2,048-document cap already sits below
99
+ * the ~3,300 articles, so articles could be truncated first. The order is not measured.
100
+ *
101
+ * Named for its scoped use because that is where it matters: the pool is divided per client, and the
102
+ * cap travels the same `search` object as `maxIndexTextBytes`.
103
+ */
104
+ export function scopedIndexMaxDocs(textBudgetBytes) {
105
+ const docs = Math.floor(textBudgetBytes / INDEX_DOC_BYTES);
106
+ return Math.min(MAX_INDEX_MAX_DOCS, Math.max(MIN_INDEX_MAX_DOCS, docs));
107
+ }
@@ -44,5 +44,14 @@ export function takeContentLoss(operation, result) {
44
44
  const stripped = paths.reduce((node, path) => take(node, path, codes), result);
45
45
  return { result: stripped, codes: [...codes].sort() };
46
46
  }
47
+ /**
48
+ * The recovery hint every discovery surface for an article body read must carry: where the stored
49
+ * HTML is. It rides the RESULT text of a lossy read ({@link lossyNote}) and the
50
+ * `hudu_describe_operation` text of the article reads, so a caller that never triggered a loss can
51
+ * still learn the recovery before it needs it.
52
+ */
53
+ export const FETCH_MANY_RECOVERY_HINT = 'A markdown_lossy read is incomplete: fetch the stored HTML with hudu_fetch_many {items:[{resource:"articles", id}]} to see all of it.';
54
+ /** The operations whose article body is converted from stored HTML to Markdown. */
55
+ export const ARTICLE_BODY_READS = new Set(Object.keys(RECORD_PATHS));
47
56
  /** The one short sentence appended to the result text when a read lost content. */
48
57
  export const lossyNote = (codes, operation) => ` markdown_lossy: true (${codes.join(', ')}): ${operation === 'articles.getSection' ? 'the Markdown read of this article is incomplete (the loss may be in another section)' : 'the Markdown body is incomplete'}; fetch the stored HTML with hudu_fetch_many {items:[{resource:"articles", id}]} to see all of it.`;
@@ -3,12 +3,12 @@ import { getCapability } from 'node-hudu/capabilities';
3
3
  import { dispatchOperation, META_TOOLS } from 'node-hudu/mcp';
4
4
  import { writeToolRegistered, assertNeverReachablePermitted, assertSecretReadPermitted, assertWritePermitted } from '../policy.js';
5
5
  import { redactCompanyContextCredentials } from './company-context-policy.js';
6
+ import { toolDescription } from './tool-prose.js';
6
7
  import { lossyNote, takeContentLoss } from './article-loss.js';
7
- import { ARTICLE_FORMAT_NOTE, contextErrorContent, errorContent, createResultBuilders, resolveErrorContent, searchErrorContent, unauthorizedContent, upfrontRefusal } from './results.js';
8
+ import { contextErrorContent, errorContent, createResultBuilders, resolveErrorContent, searchErrorContent, unauthorizedContent, upfrontRefusal } from './results.js';
8
9
  import { INVOKE_OUTPUT } from './schemas.js';
10
+ import { SEARCH_DEFAULT_LIMIT, awaitWithProgress, cancelledSearchContent, progressTokenOf, signalOf } from './search.js';
9
11
  import { FETCH_MANY_OPERATION, applyFetchManyPolicyToInvoke, SEARCH_ACROSS_OPERATION, applyAcrossPolicyToInvoke, credentialRefused, RESOLVE_ANY_OPERATION, SEARCH_KNOWLEDGE_OPERATION, applyResolvePolicyToInvoke, applySearchPolicyToInvoke, sanitizeIndexMeta, sanitizeSearchFailures } from './search-policy.js';
10
- /** The SDK's curated text cites its own tracker ("(issues #70/#72)"); that is noise to a model and an internal reference. */
11
- const stripIssueRefs = (text) => text.replace(/\s*\(issues? #\d+(?:[/, ]+#?\d+)*\)/g, '');
12
12
  export function registerDispatchTools(server, ctx) {
13
13
  const { ok } = createResultBuilders(ctx.huduOrigin);
14
14
  const { config, log } = ctx;
@@ -27,7 +27,7 @@ export function registerDispatchTools(server, ctx) {
27
27
  throw new Error(`Missing SDK ${mode} metadata`);
28
28
  server.registerTool(spec.name, {
29
29
  title: spec.title,
30
- description: stripIssueRefs(spec.description) + (mode === 'delete' ? '' : ARTICLE_FORMAT_NOTE),
30
+ description: toolDescription(spec.name),
31
31
  inputSchema: z.strictObject({
32
32
  operation: z.enum(operations).describe(field('operation')),
33
33
  input: z.record(z.string(), z.unknown()).optional().describe(field('input')),
@@ -35,8 +35,12 @@ export function registerDispatchTools(server, ctx) {
35
35
  }),
36
36
  outputSchema: INVOKE_OUTPUT,
37
37
  annotations: spec.annotations,
38
- }, async (args) => {
38
+ }, async (args, req) => {
39
39
  const { operation, input, dry_run, confirm } = args;
40
+ // True once control reaches the SDK dispatcher: every policy gate above it refuses before any
41
+ // request, so those refusals never clear. Past this point a validation refusal also clears;
42
+ // that is a cheap no-op, and telling it apart from a write that dialed is not worth the code.
43
+ let dispatched = false;
40
44
  try {
41
45
  // Deployment policy narrows the SDK mode and effect gate.
42
46
  assertWritePermitted(operation, getCapability(operation), config, log);
@@ -50,8 +54,14 @@ export function registerDispatchTools(server, ctx) {
50
54
  // scope as its tool rather than bypassing the narrowed schema. `operations.fetchMany` is
51
55
  // the same kind of read edge, through items: its key is not a secret operation, so the
52
56
  // assert above passes it, and only the item-level check closes the credential-read gap.
53
- const bag = operation === SEARCH_KNOWLEDGE_OPERATION
54
- ? applySearchPolicyToInvoke(ctx, input ?? {})
57
+ const searchPolicy = operation === SEARCH_KNOWLEDGE_OPERATION ? applySearchPolicyToInvoke(ctx, input ?? {}) : undefined;
58
+ const tierOverridden = searchPolicy?.tierOverridden === true;
59
+ // The ORIGINAL input opts, before policy replaced tier/refresh: these are the fields the
60
+ // caller actually sent, so `meta.policyOverride.stripped` names exactly those.
61
+ const rawOpts = (input !== undefined && typeof input.opts === 'object' && input.opts !== null ? input.opts : {});
62
+ const stripped = ['tier', 'refresh'].filter((field) => rawOpts[field] !== undefined);
63
+ const bag = searchPolicy !== undefined
64
+ ? searchPolicy.bag
55
65
  : operation === RESOLVE_ANY_OPERATION
56
66
  ? applyResolvePolicyToInvoke(ctx, input ?? {})
57
67
  : operation === SEARCH_ACROSS_OPERATION
@@ -72,7 +82,46 @@ export function registerDispatchTools(server, ctx) {
72
82
  opts.dryRun = dry_run;
73
83
  if (typeof confirm === 'string')
74
84
  opts.confirm = confirm;
75
- let result = await dispatchOperation(client, effect, operation, bag, opts);
85
+ // `operations.searchKnowledge` can WAIT on a corpus-wide body-index walk: tier:"index" or
86
+ // refresh:true under stdio, and an http deployment whose tier policy is "index". That wait
87
+ // is no different from the one `hudu_search` makes, so it gets the same progress ticks and
88
+ // the same honest cancelled answer — a silent wait reads as a hang. The 2 s first tick
89
+ // keeps a fast call silent.
90
+ const searchOpts = (operation === SEARCH_KNOWLEDGE_OPERATION ? (bag.opts ?? {}) : {});
91
+ const searchWaits = operation === SEARCH_KNOWLEDGE_OPERATION && (searchOpts.tier === 'index' || searchOpts.refresh === true);
92
+ // The abort check precedes the work: the SDK walk is not abortable, so a request that is
93
+ // already gone must not start one (its answer then reports a cold index truthfully).
94
+ if (searchWaits && signalOf(req)?.aborted === true) {
95
+ const cancelled = cancelledSearchContent({
96
+ query: typeof bag.query === 'string' ? bag.query : '',
97
+ scope: (Array.isArray(searchOpts.scope) ? searchOpts.scope : ctx.defaultSearchScope),
98
+ limit: typeof searchOpts.limit === 'number' ? searchOpts.limit : SEARCH_DEFAULT_LIMIT,
99
+ elapsedMs: 0,
100
+ waited: false,
101
+ transport: config.MCP_TRANSPORT,
102
+ });
103
+ return ok(cancelled.text, { simulated: false, operation, result: cancelled.data });
104
+ }
105
+ dispatched = true;
106
+ let result;
107
+ if (searchWaits) {
108
+ const awaited = await awaitWithProgress(req, progressTokenOf(req), dispatchOperation(client, effect, operation, bag, opts), log);
109
+ if (awaited.aborted) {
110
+ const cancelled = cancelledSearchContent({
111
+ query: typeof bag.query === 'string' ? bag.query : '',
112
+ scope: (Array.isArray(searchOpts.scope) ? searchOpts.scope : ctx.defaultSearchScope),
113
+ limit: typeof searchOpts.limit === 'number' ? searchOpts.limit : SEARCH_DEFAULT_LIMIT,
114
+ elapsedMs: awaited.elapsedMs,
115
+ waited: awaited.waited,
116
+ transport: config.MCP_TRANSPORT,
117
+ });
118
+ return ok(cancelled.text, { simulated: false, operation, result: cancelled.data });
119
+ }
120
+ result = awaited.value;
121
+ }
122
+ else {
123
+ result = await dispatchOperation(client, effect, operation, bag, opts);
124
+ }
76
125
  // After the SDK's own input validation, so bad input stays CONFIG_ERROR. The refusing
77
126
  // placeholder never dials, so nothing reached Hudu; the engine swallows its error into a
78
127
  // 0-hit result (searchKnowledge, an isolating searchAcrossResources) or per-item rows
@@ -103,9 +152,17 @@ export function registerDispatchTools(server, ctx) {
103
152
  if (credentialRefused(meta, result.hits))
104
153
  return unauthorizedContent();
105
154
  const failures = sanitizeSearchFailures(meta.failed ?? meta.errors);
155
+ // A tier/refresh the deployment policy overrode is announced in its own field, never
156
+ // silently swapped and never mixed into `reasons` (which names BOUNDS that bit).
106
157
  result = {
107
158
  ...result,
108
- meta: { ...meta, errors: failures, failed: failures, index: sanitizeIndexMeta(meta.index) },
159
+ meta: {
160
+ ...meta,
161
+ ...(tierOverridden ? { policyOverride: { stripped, appliedTier: bag.opts?.tier } } : {}),
162
+ errors: failures,
163
+ failed: failures,
164
+ index: sanitizeIndexMeta(meta.index),
165
+ },
109
166
  };
110
167
  }
111
168
  }
@@ -120,7 +177,8 @@ export function registerDispatchTools(server, ctx) {
120
177
  }
121
178
  const simulated = result !== null && typeof result === 'object' && result.simulated === true;
122
179
  // A Markdown article read that lost content says so; a lossless read is byte-identical to before.
123
- return ok((simulated ? `Dry run: nothing was written (${operation}).` : `${operation} completed.`) + (lossy.length ? lossyNote(lossy, operation) : ''), {
180
+ const overrideNote = tierOverridden ? ` The ${stripped.join('/')} field(s) this call carried were overridden by the server search tier policy.` : '';
181
+ return ok((simulated ? `Dry run: nothing was written (${operation}).` : `${operation} completed.`) + overrideNote + (lossy.length ? lossyNote(lossy, operation) : ''), {
124
182
  simulated,
125
183
  operation,
126
184
  result: result ?? null,
@@ -136,6 +194,21 @@ export function registerDispatchTools(server, ctx) {
136
194
  return searchErrorContent(err);
137
195
  return operation === RESOLVE_ANY_OPERATION ? resolveErrorContent(err) : errorContent(err);
138
196
  }
197
+ finally {
198
+ // Each effect has its own cached client, so a write never clears the read client's cache
199
+ // (node-hudu clears only the client it mutates through). Clear the read client of this scope
200
+ // after every real write or delete ATTEMPT, in `finally`: a timeout or 5xx may still have
201
+ // applied. A dry run (`dry_run` absent is forced to one by the SDK) sends no write, so it
202
+ // does not clear. clearCache never throws; the guard makes sure it cannot alter the result.
203
+ if (mode !== 'read' && dispatched && dry_run === false) {
204
+ try {
205
+ ctx.hudu.clearCache();
206
+ }
207
+ catch (e) {
208
+ log.warn('read cache clear failed after write', { operation, error: e instanceof Error ? e.message : String(e) });
209
+ }
210
+ }
211
+ }
139
212
  });
140
213
  }
141
214
  }
@@ -1,5 +1,5 @@
1
1
  import * as z from 'zod/v4';
2
- import { ARTICLE_FORMAT_NOTE, errorContent, metaSpec, createResultBuilders, upfrontRefusal } from './results.js';
2
+ import { errorContent, metaSpec, createResultBuilders, upfrontRefusal } from './results.js';
3
3
  import { applyFetchManyPolicyToInvoke } from './search-policy.js';
4
4
  /**
5
5
  * The 22 bare-id resources the batch read serves — the closed `items.resource` enum.
@@ -44,7 +44,6 @@ export function registerFetchManyTool(server, ctx) {
44
44
  const { ok } = createResultBuilders(ctx.huduOrigin);
45
45
  server.registerTool('hudu_fetch_many', {
46
46
  ...metaSpec('hudu_fetch_many'),
47
- description: metaSpec('hudu_fetch_many').description + ARTICLE_FORMAT_NOTE,
48
47
  inputSchema: FETCH_MANY_INPUT,
49
48
  outputSchema: FETCH_MANY_OUTPUT,
50
49
  }, async (args) => {