iit-mcp-hudu 0.6.0 → 0.6.1

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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ **Upgrading from 0.6.0:** no configuration change. Each scoped client's body index is deeper: a read-only http deployment gives one credential a third of the pool instead of an eighth. A write-enabled deployment on a small heap gets a shallower index than 0.6.0 (a ninth of the pool), because the eviction margin and the per-mode caches are both counted.
6
+
7
+ - Fix: a read-only http deployment now gives each scoped client a THIRD of the pooled index text budget instead of an eighth (the pool is divided by two clients per cache plus one eviction of margin), so the body index is deep enough to search the articles it holds. MEASURED on the production tenant (2026-10-26) with the former division by eight at 1 GiB: a scope retained 4,666 assets and 1,478 of 3,204 articles; 1,036 article bodies were indexed, 317 had been evicted by the text budget and 3 truncated (the counts do not decompose the 1,478 — some retained articles carry no body text). The cause is the SDK's document cap, which keeps the freshest `updated_at` across ALL resources, so newer assets displaced older articles. A read-only deployment at a ~816 MB V8 ceiling now gets 17 MiB of text and 17,408 documents per scope instead of 6 MiB and 6,144 (INFERRED to keep all 3,204 articles and about 2,800 of their bodies, from the measured article density at 6,144; the SDK's cap is global, so this is an expectation, not a guarantee); a 512 MiB container gets 5.33 MiB and 5,461 instead of 2 MiB and 2,048. The document cap lost its 1,500-document floor and now follows the share (one document per KiB): the smallest pool with writes (an 8 MiB pool, 932,067 bytes per client) gets 910 documents instead of a 1,500-document cap above its own share. The pool is also divided by the number of per-mode caches: a read-only deployment creates one (`read`), a write-enabled deployment three (`read`, `write`, `delete`), because each mode entry keeps its own cache of scoped clients, and each cache's clients are counted with one extra eviction transient (the scope the LRU just dropped while an in-flight request finishes). So a write-enabled deployment gets 5.67 MiB and 5,802 per scope at ~816 MB, and a write-enabled 512 MiB container gets LESS than before (1.78 MiB and 1,820 instead of 2 MiB and 2,048) because the eviction margin is now counted, and in every profile the share is sized so the cache capacity (clients x caches) plus one eviction per cache stays within the pool; concurrent evictions beyond that margin can exceed it until their requests finish, because the SDK cannot revoke a live index. This also corrects a pre-existing oversubscription, since with eight shares three caches could hold 144 MiB of text against a 51 MiB pool. The trade is breadth for depth: at most two (origin, credential) pairs stay warm per mode cache, and a credential occupies one client per cache (read, plus write and delete when writes are enabled), so a deployment serving more credentials also loses each evicted scope's read-through TTL cache and re-fetches more from Hudu, and rebuilds an evicted index on demand (searches answer from the vendor tier meanwhile, unless `HUDU_SEARCH_TIER_POLICY=index` makes the call wait on that rebuild, with progress and cancellation), and a deployment needing both breadth and depth needs a larger heap. `MAX_SCOPED_CLIENTS` still equals `SCOPED_INDEX_BUDGET_DIVISOR` (now 2); the per-mode cache count (3: read, write, delete) is the second divisor, and `SCOPED_INDEX_EVICTION_TRANSIENT` (1) is added to the per-cache count before multiplying — it is a margin, not a third multiplicand.
8
+
3
9
  ## 0.6.0
4
10
 
5
11
  **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.
package/README.md CHANGED
@@ -158,8 +158,10 @@ gate on who can use it. It is only ever presented to the default
158
158
  `HUDU_BASE_URL`: a request that names some other origin and brings no key of its own gets no
159
159
  credential and fails closed, so a caller cannot aim the server's key at a host it controls. (A
160
160
  blank value, as a copied `.env.example` leaves it, is treated as unset.) Each (origin, credential)
161
- pair gets its own scoped client (its own search index) cached by the pair's hash and capped, so
162
- one caller's indexed article bodies can never answer another's search.
161
+ pair gets its own scoped client (its own search index) cached by the pair's hash and capped — at most
162
+ two pairs stay warm in EACH mode cache, so a credential occupies one client per cache (read, plus
163
+ write and delete when writes are enabled) and a further scope rebuilds its index on demand — so one
164
+ caller's indexed article bodies can never answer another's search.
163
165
 
164
166
  `HUDU_ALLOWED_BASE_HOSTS` (`http` only; required unless `HUDU_ALLOW_ANY_BASE_HOST=true`) restricts which origins a caller may name in
165
167
  `X-Hudu-Base-Url`: a comma-separated list of exact hostnames or `*.example.com` subdomain suffixes
@@ -298,7 +300,7 @@ surface headroom); the meta vocabulary is enforced by the tests that publish it
298
300
 
299
301
  The body index is bounded by TWO caps derived from the same heap estimate: a text budget and a
300
302
  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
303
+ the document cap is one document per KiB of that budget (`maxDocs`), capped at 50,000. Both
302
304
  travel the SDK's `search` options, so neither is an env knob. The divisor moved from 10 to 16 in
303
305
  round 11 because the tenth left no usable production margin (the arithmetic below).
304
306
 
@@ -308,28 +310,42 @@ of heap and died with `Reached heap limit` mid-search. It measured the ~90 MB ba
308
310
  4 bytes of index structures per byte of text ACTUALLY HELD, not per byte of budget: that index held
309
311
  ~41 MB of text out of its 64 MiB budget, and the peak reconciles as ~90 + 4 x 41 = ~254 MB. Read
310
312
  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 trace never saw. A 512 MiB container (roughly a 259 MB heap ceiling) now gets a 16 MiB pool,
314
+ divided THREE ways for one read-only cache (5.33 MiB per scoped client) or nine ways when writes are
315
+ enabled, because read, write and delete each keep their own cache and each counts one eviction of
316
+ margin (1.78 MiB per client). The division is an exact byte quotient, so no pool space is lost to
317
+ rounding. The share is SIZED so the cache capacity plus one eviction of margin per cache fits the
318
+ pool; it is not an enforced peak. A scope evicted by the LRU stays resident while an in-flight
319
+ request that resolved it finishes, and because the SDK cannot revoke a live index, concurrent
320
+ evictions of distinct credentials can exceed the pool until those requests settle. A standalone walk of a synthetic 25,000-asset / 3,300-article tenant through
313
321
  the real SDK client under a ~262 MB V8 ceiling (an uncommitted one-off probe; `--max-semi-space-size=1`
314
322
  brings V8's reported limit to ~262 MB, while `--max-old-space-size=259` alone reports ~451 MB in
315
323
  Node 24) peaked at ~136 MiB with the ROUND-10 tenth settings; the same walk with the OLD
316
324
  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.
325
+ 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 17 MiB per scoped client (5.67 MiB with writes enabled); a 1024 MB heap ceiling is
326
+ what reaches the 64 MiB cap.
318
327
 
319
328
  The document cap exists because the text budget bounds index TEXT only: a document whose text was
320
329
  evicted keeps its record and postings, the SDK's own default is 50,000 documents, and a walk can
321
330
  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
331
+ container indexes up to 5,461 documents per scoped client read-only, 1,820 with writes (a stdio
332
+ parent carries the whole pool instead: 16,384 documents; writes enabled builds one parent per mode,
333
+ so up to three of them carry it), a 1 GiB container's ~816 MB ceiling (assumed ~80%) 17,408 per
334
+ client read-only, 5,802 with writes (50,000 in a stdio parent), and only a 1024 MB ceiling 21,845
335
+ read-only, 7,281 with writes. The per-document cost is an ESTIMATE (~1 KB of record
324
336
  plus postings per document, on top of the text ratio), so the pair bounds growth without proving a
325
337
  peak. HONEST SCOPE: the SDK upserts every walked document and calls its cap only AFTER the walk, so
326
338
  `maxDocs` bounds the RETAINED index, not the build-time peak; the peak during a walk is bounded by
327
339
  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.
340
+ `partial`, exactly as it already does when the text budget bites. WHICH documents the cap keeps is
341
+ the SDK's choice: the freshest `updated_at` across ALL resources, so assets can outrank older
342
+ articles. MEASURED on the production tenant with the former division by eight (6,144 documents):
343
+ 4,666 assets and 1,478 of 3,204 articles retained; 1,036 article bodies indexed, 317 evicted by the
344
+ text budget and 3 truncated (the counts do not decompose the 1,478 — some retained articles carry no
345
+ body text). The same measurement is why a read-only scope now gets a THIRD of the pool instead of an
346
+ eighth: 17 MiB and 17,408 documents at 1 GiB are expected (INFERRED, not measured at this division)
347
+ to keep all 3,204 articles and about 2,800 of their bodies, while at 512 MiB (5,461 documents) 3,204
348
+ articles leave only 2,257 slots for assets and the loss remains.
333
349
 
334
350
  BEFORE THE DIVISOR CHANGE. The round-10 end-to-end run measured the real server (`dist/main.js`) under
335
351
  a 259 MiB V8 ceiling: peak heapUsed 187.96 MB with a 24.6 MB baseline, so the INDEX STRUCTURES cost
@@ -430,7 +446,9 @@ Every client the server builds — the parent, the fail-closed placeholders and
430
446
  blindly.
431
447
  - `cache: 'recommended'` — fixed on: the
432
448
  per-entity read-through TTL preset (1 h structural / 5 min mutable / 1 min base). Caches are
433
- per client, so per scope. The write and delete clients have their own caches, so after every `hudu_write` /
449
+ per client, so per scope — and a scope's client survives only while it stays in its mode cache,
450
+ which holds two pairs: a deployment serving more than two credentials therefore re-fetches more
451
+ from Hudu after an eviction, the same breadth-for-depth trade the body index makes. The write and delete clients have their own caches, so after every `hudu_write` /
434
452
  `hudu_delete` attempt that is not a dry run (including a failed or timed-out one, which may have
435
453
  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
454
  the same server process is fresh. A write made by another process or deployment can still be served
@@ -7,12 +7,47 @@ export const MIN_INDEX_TEXT_BYTES = 8 * MIB;
7
7
  /** 64 MiB: the SDK's own default text bound, and the most a single scope should ever hold. */
8
8
  export const MAX_INDEX_TEXT_BYTES = 64 * MIB;
9
9
  /**
10
- * How many scoped clients share the pooled text budget (the cache cap in `tools/server.ts`).
10
+ * How many scoped clients ONE per-mode cache keeps warm (the cache capacity in `tools/server.ts`).
11
11
  *
12
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.
13
+ * must be divided rather than handed to each client whole — by this number, by the number of
14
+ * per-mode caches the factory builds (`read`, plus `write` and `delete` when writes are enabled),
15
+ * and by one eviction transient per cache.
16
+ *
17
+ * TWO, not eight, because a division by eight made the index shallower than the corpus it exists to
18
+ * search: on the production tenant at 1 GiB each scope got 6 MiB of text and 6,144 documents, and
19
+ * because the SDK's document cap keeps the freshest `updated_at` GLOBALLY (it has no per-resource
20
+ * cap), 4,666 newer assets displaced 1,726 of the 3,204 articles, leaving 1,036 article bodies
21
+ * indexed (MEASURED on the live server, 2026-10-26).
22
+ *
23
+ * RESULTING DEPTH: a read-only deployment (one cache, `HUDU_WRITE_POLICY=deny`) gives each scope
24
+ * 17 MiB and 17,408 documents at a ~816 MB ceiling; that is INFERRED to cover the tenant's article
25
+ * bodies almost entirely (about 2,800 of its 3,204 articles) and to keep all 3,204 articles, because
26
+ * the measured run retained articles at roughly a quarter of the freshest-document density. A
27
+ * write-enabled deployment has three caches, so the same pool is divided by nine and each scope gets
28
+ * 5.67 MiB and 5,802 documents.
29
+ *
30
+ * SIZED FOR, NOT ENFORCED: the share is the pool divided by `(capacity + 1) x caches`, where the +1
31
+ * leaves one eviction of margin per cache — the scope the LRU just dropped, which an in-flight
32
+ * request keeps alive until it finishes (`ScopedClientCache.for()`). That makes the steady-state
33
+ * bound `capacity x caches x share <= pool` hold, with one eviction per cache on top. It cannot
34
+ * enforce a peak: k distinct credentials arriving at once can each evict a different scope, and the
35
+ * SDK has no way to revoke a live index, so up to `capacity + k` scopes per cache can be alive at
36
+ * once. The pool is a sizing target, as it has been since #51, not a hard cap.
37
+ *
38
+ * This also CORRECTS a pre-existing oversubscription: with eight shares, three caches could hold
39
+ * 3 x 8 x 6 = 144 MiB of text against a 51 MiB pool. The trade is still breadth for depth: an
40
+ * evicted scope rebuilds on demand, and answers fall back to the vendor tier meanwhile — unless the
41
+ * deployment forces `HUDU_SEARCH_TIER_POLICY=index`, where the call waits on that rebuild (with
42
+ * progress and cancellation). A deployment needing both breadth and depth needs a larger heap.
14
43
  */
15
- export const SCOPED_INDEX_BUDGET_DIVISOR = 8;
44
+ export const SCOPED_INDEX_BUDGET_DIVISOR = 2;
45
+ /**
46
+ * One eviction of margin per cache: a scope evicted by the LRU stays reachable while an in-flight
47
+ * request that resolved it finishes, so the cache can briefly hold capacity + 1 shares. Concurrent
48
+ * evictions (k distinct credentials at once) can exceed this; see the divisor note.
49
+ */
50
+ export const SCOPED_INDEX_EVICTION_TRANSIENT = 1;
16
51
  /**
17
52
  * The total extracted text the PROCESS's body indexes may hold, from the V8 heap ceiling in bytes.
18
53
  *
@@ -39,29 +74,47 @@ export const SCOPED_INDEX_BUDGET_DIVISOR = 8;
39
74
  * walk.
40
75
  *
41
76
  * 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.
77
+ * {@link SCOPED_INDEX_BUDGET_DIVISOR} scoped clients stay warm in each per-mode cache and each owns a
78
+ * body index, so each is handed only {@link scopedIndexTextBudgetBytes}: the pool divided by
79
+ * ({@link SCOPED_INDEX_BUDGET_DIVISOR} clients + {@link SCOPED_INDEX_EVICTION_TRANSIENT} eviction of
80
+ * margin) times the number of caches. The trade: several credentials build shallower indexes (a
81
+ * scope that hits its share reports index state "partial"); needing depth means raising the
82
+ * container memory or serving over stdio, where the serving clients own the whole pool (one parent
83
+ * per mode there, so writes enabled can hold it in three parents; `hudu_search` uses the read one).
84
+ *
85
+ * MEASURED on the production tenant (2026-10-26), with the former division by eight: a scope retained
86
+ * 4,666 assets and 1,478 of 3,204 articles, indexed 1,036 article bodies, and had evicted 317 bodies
87
+ * by the text budget and truncated 3 (the counts do not decompose the 1,478: some retained articles
88
+ * carry no body text). The SDK's document cap keeps the freshest documents across ALL resources,
89
+ * which is the shallowness this divisor fixes.
46
90
  */
47
91
  export function indexTextBudgetBytes(heapLimitBytes) {
48
92
  const sixteenthInMib = Math.floor(heapLimitBytes / 16 / MIB) * MIB;
49
93
  return Math.min(MAX_INDEX_TEXT_BYTES, Math.max(MIN_INDEX_TEXT_BYTES, sixteenthInMib));
50
94
  }
51
95
  /**
52
- * The text budget for ONE scoped client: the process pool divided by the scoped-client cap.
96
+ * The text budget for ONE scoped client: the process pool divided by the clients the design expects
97
+ * it to fund — (cache capacity + one eviction of margin) x per-mode caches.
53
98
  *
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.
99
+ * `caches` is the number of scoped-client caches the process creates: 1 under
100
+ * `HUDU_WRITE_POLICY=deny` (read only), 3 otherwise (read, write, delete). It is required, not
101
+ * defaulted: a default of 1 would size every client for a read-only process and oversubscribe a
102
+ * write-enabled one threefold.
103
+ *
104
+ * The share is an exact byte quotient, so `clients x share` never exceeds the pool and no pool space
105
+ * is lost to rounding — whole-MiB rounding WOULD have discarded 7 of 16 MiB (44%) in the 512 MiB
106
+ * write-enabled profile, and up to 8 of 17 MiB in the worst case. Small pools get a share below the single-client
107
+ * {@link MIN_INDEX_TEXT_BYTES} on purpose — the clients cannot each hold the single-client floor out
108
+ * of an 8 MiB pool — and the SDK accepts any positive bound (a probe with 932,067 bytes built and
109
+ * answered `warm`).
57
110
  */
58
- export function scopedIndexTextBudgetBytes(heapLimitBytes) {
111
+ export function scopedIndexTextBudgetBytes(heapLimitBytes, caches) {
59
112
  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));
113
+ const clients = (SCOPED_INDEX_BUDGET_DIVISOR + SCOPED_INDEX_EVICTION_TRANSIENT) * Math.max(1, caches);
114
+ return Math.max(1, Math.floor(total / clients));
62
115
  }
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;
116
+ /** 1: the cap follows the share's KiB count, so it never exceeds the text budget it is paired with. */
117
+ export const MIN_INDEX_MAX_DOCS = 1;
65
118
  /** 50,000: the SDK's own default doc count, and the most one index should ever hold. */
66
119
  export const MAX_INDEX_MAX_DOCS = 50_000;
67
120
  /** ~1 KB of record + postings per indexed document, on top of the ~4x text ratio. ESTIMATE. */
@@ -81,22 +134,32 @@ const INDEX_DOC_BYTES = 1024;
81
134
  * reach 250 pages x 100 rows x 2 body resources, and a document whose text the budget evicted keeps
82
135
  * its record and postings, so the record overhead grows even when no text is added. This caps the
83
136
  * 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.
137
+ * text budget, capped at 50,000 and never below one document.
85
138
  *
86
139
  * ESTIMATE, and it bounds growth rather than proving a peak: the pairing says "this index may hold X
87
140
  * MiB of text and Y documents", not "the process cannot exceed its heap". The SDK upserts every
88
141
  * walked document and applies the cap only AFTER the walk, so this bounds what the index RETAINS,
89
142
  * 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.
143
+ * reports index state "partial", as it already does when the text budget bites. Resulting numbers,
144
+ * read-only profile / write-enabled profile (one cache / three caches):
145
+ * a 512 MiB container (MEASURED ~50.6% container-to-heap ratio: ~259 MB) gets a 16 MiB pool, so
146
+ * 5.33 MiB / 5,461 documents per scoped client read-only, 1.78 MiB / 1,820 with writes (a stdio
147
+ * parent carries the whole pool: 16,384 documents, and writes enabled builds one such parent per
148
+ * mode); a 1 GiB container's ~816 MB V8 ceiling — an ASSUMED ~80% ratio, not a measurement — gets a
149
+ * 51 MiB pool, so 17 MiB / 17,408 read-only, 5.67 MiB / 5,802 with writes (50,000 in a stdio parent);
150
+ * a 1024 MB ceiling reaches the 64 MiB cap, so 21.33 MiB / 21,845 read-only, 7.11 MiB / 7,281 with
151
+ * writes.
152
+ *
153
+ * WHICH documents the cap truncates is the SDK's choice, not this module's: it keeps the freshest
154
+ * `updated_at` across ALL resources, so assets can outrank older articles. MEASURED on the production
155
+ * tenant at the former division by eight (6,144 documents): 4,666 assets and 1,478 of the 3,204
156
+ * articles were retained; 1,036 article bodies were indexed, 317 had been evicted by the text budget
157
+ * and 3 truncated (the counts do not decompose the 1,478 — some retained articles carry no body
158
+ * text). At the 1 GiB read-only figures above (17 MiB and 17,408 documents) the measured article
159
+ * density (1,478 of the freshest 6,144) implies all 3,204 articles now fall inside the cap, so the
160
+ * article loss is expected to disappear (INFERRED, not measured at this division); at 512 MiB
161
+ * (5,461 documents) 3,204 articles would leave only 2,257 slots for assets, so the loss remains. The
162
+ * order is the SDK's.
100
163
  *
101
164
  * Named for its scoped use because that is where it matters: the pool is divided per client, and the
102
165
  * cap travels the same `search` object as `maxIndexTextBytes`.
@@ -174,10 +174,13 @@ export function applySearchPolicyToInvoke(ctx, input) {
174
174
  throw configError(`hudu_read / hudu_write / hudu_delete: operations.searchKnowledge scope ${JSON.stringify(excluded)} is excluded by HUDU_SEARCH_RESOURCES; this deployment may search: ${searchableResources.join(', ')}. Use hudu_search, which applies the same policy.`);
175
175
  }
176
176
  }
177
- // Under `http` the tier is deployment policy, so this path takes the same non-blocking route as
178
- // `hudu_search`: a caller-supplied `tier` or `refresh` cannot make the call wait for a walk. That
179
- // override is REPORTED, never silent: the dispatcher appends `tier-policy-override` to
180
- // `meta.reasons` and a clause to the response text.
177
+ // Under `http` the tier is deployment policy, so this path takes the same server-side route as
178
+ // `hudu_search`: a caller-supplied `tier` or `refresh` cannot widen it. Whether that route waits
179
+ // for a corpus walk is policy, not the caller: `auto` (the default) answers from the index when
180
+ // it is warm and from the vendor tier while it builds, never waiting; `HUDU_SEARCH_TIER_POLICY=index`
181
+ // waits for a complete index on both paths. That override is
182
+ // REPORTED, never silent: the dispatcher adds `meta.policyOverride` (`{ stripped, appliedTier }`)
183
+ // and a clause to the response text.
181
184
  if (searchTierIsPolicy) {
182
185
  return {
183
186
  bag: { ...input, opts: { ...opts, scope, tier: toolDefaultTier, refresh: false } },
@@ -22,12 +22,13 @@ import { slimToolSchemas } from './slim.js';
22
22
  const SERVER_NAME = 'iit-mcp-hudu';
23
23
  const SERVER_VERSION = createRequire(import.meta.url)('../../package.json').version;
24
24
  /**
25
- * The maximum number of per-(origin, credential) scoped clients kept warm in one process.
25
+ * The maximum number of per-(origin, credential) scoped clients ONE mode cache keeps warm in a process.
26
26
  *
27
27
  * Each scoped client owns its own Hudu knowledge index. An unbounded map would let enough distinct
28
28
  * callers grow the heap past the container limit, so the cache is capped and evicts
29
- * least-recently-used first. The cap is the same number the process-wide index text budget is
30
- * divided by (`search-budget.ts`), so eight clients share one pool instead of each claiming it.
29
+ * least-recently-used first. The cap is the per-cache client count the process-wide index text
30
+ * budget is divided by (`search-budget.ts`); the factory builds one such cache per mode entry, and
31
+ * the budget is divided by that count too, so the caches together stay inside the pool.
31
32
  */
32
33
  export const MAX_SCOPED_CLIENTS = SCOPED_INDEX_BUDGET_DIVISOR;
33
34
  /**
@@ -404,7 +405,11 @@ export function createMcpServerFactory(requested, log) {
404
405
  // The body index's text budget follows the process's own heap ceiling, so a small container
405
406
  // reports index state "partial" instead of dying at the V8 heap limit (#51). This value is the
406
407
  // PROCESS-wide pool: the scoped clients below each take a share of it, since each materialises
407
- // its own index. The parent client keeps the pool because under `stdio` it is the only client.
408
+ // its own index. The parent keeps the pool because under `stdio` it is the serving client and
409
+ // depth is the point of serving that way; with writes enabled there is one parent per mode
410
+ // (`read`, `write`, `delete`), so a raw `operations.searchKnowledge` invoked through the write or
411
+ // delete parent could build a second full-pool index (the `hudu_search` path uses the read
412
+ // parent only).
408
413
  // HUDU_SEARCH_INDEX_TTL, when set, is the body index's TTL (the SDK default is 10 minutes).
409
414
  search: {
410
415
  maxIndexTextBytes: poolTextBytes,
@@ -443,11 +448,17 @@ export function createMcpServerFactory(requested, log) {
443
448
  ? [...reads, ...config.HUDU_WRITE_ALLOW.filter((key) => getCapability(key)?.effect === (mode === 'delete' ? 'destructive' : 'write')).flatMap((key) => withWriteDependencies(key, mode === 'delete' ? 'destructive' : 'write'))]
444
449
  : undefined;
445
450
  const scopedOptions = { ...options, mode, reveal: false, ...(allowOperations ? { allowOperations } : {}) };
446
- // ONE scoped client's share of the process pool: the cache can hold MAX_SCOPED_CLIENTS of them,
447
- // so handing each the whole pool would let the process hold 8x the text it was sized for (#51).
451
+ // ONE scoped client's share of the process pool: each per-mode cache holds MAX_SCOPED_CLIENTS
452
+ // clients plus one eviction transient, and the process builds one cache per mode (`modes`,
453
+ // below), so handing each the whole pool would let it hold that many times the text it was
454
+ // sized for (#51). Divide by both factors, using the cache count the policy implies rather than
455
+ // how many are warm.
448
456
  // Only the per-(origin, credential) clients are divided — the parent keeps the pool, because
449
- // under `stdio` it is the only client and a deeper index is the point of serving that way.
450
- const scopedTextBytes = scopedIndexTextBudgetBytes(heapLimitBytes);
457
+ // under `stdio` it is the serving client and a deeper index is the point of serving that way
458
+ // (one parent per mode there, so up to three pool-sized clients with writes enabled; the
459
+ // `hudu_search` path uses the read parent only).
460
+ const indexCaches = modes.length;
461
+ const scopedTextBytes = scopedIndexTextBudgetBytes(heapLimitBytes, indexCaches);
451
462
  const scopedSearch = { ...options.search, maxIndexTextBytes: scopedTextBytes, maxDocs: scopedIndexMaxDocs(scopedTextBytes) };
452
463
  const parent = config.MCP_TRANSPORT === 'stdio'
453
464
  ? new HuduClient({ ...scopedOptions, apiKey: config.HUDU_API_KEY })
@@ -475,8 +486,14 @@ export function createMcpServerFactory(requested, log) {
475
486
  scopes: new ScopedClientCache((baseUrl, apiKey) => new HuduClient({ ...scopedOptions, baseUrl, apiKey, blockPrivateHosts: true, search: scopedSearch }), MAX_SCOPED_CLIENTS, log),
476
487
  };
477
488
  };
478
- const read = build('read');
479
- const mutations = config.HUDU_WRITE_POLICY === 'deny' ? undefined : { write: build('write'), delete: build('delete') };
489
+ // The per-mode caches this factory builds. ONE list drives both the caches built and the pool
490
+ // division (`indexCaches` above), so a change to the policy mapping cannot leave the budget
491
+ // describing a different number of caches than the process creates.
492
+ const writeModes = config.HUDU_WRITE_POLICY === 'deny' ? [] : ['write', 'delete'];
493
+ const modes = ['read', ...writeModes];
494
+ const built = new Map(modes.map((mode) => [mode, build(mode)]));
495
+ const read = built.get('read');
496
+ const mutations = writeModes.length === 0 ? undefined : { write: built.get('write'), delete: built.get('delete') };
480
497
  return (ctx) => {
481
498
  const resolve = (entry) => resolveHuduClient(entry, config, ctx, log);
482
499
  // Per serving unit (one stdio connection, or one HTTP request): the anomalies of the credential
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-hudu",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "MCP server for Hudu IT documentation, built on the node-hudu SDK",
5
5
  "license": "MIT",
6
6
  "author": "Max Soukhomlinov",