iit-mcp-hudu 0.4.0 → 0.5.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.5.1
6
+
7
+ **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.
8
+
9
+ - 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).
10
+ - Dependency: node-hudu 0.18.0 -> 0.19.0 (exact pin). Plan hash unchanged (`9d110798...`); operation set unchanged (236).
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+
15
+ ## 0.5.0
16
+
17
+ **Upgrading from 0.4.0:** clients now see `markdown_lossy` / `markdown_lossy_codes` on `articles.getSection`, with a section-specific note (the signal describes the whole article, so the loss may be in another section). Whole-article read text is unchanged. `hudu_list` compact rows are unchanged. node-hudu 0.17.0 -> 0.18.0, plan hash re-stamped to `9d110798...`, operation set unchanged (236).
18
+
19
+ - `articles.getSection` lifts the SDK's whole-body `content_loss` into `markdown_lossy` / `markdown_lossy_codes` like the other article reads; `content_loss` is never passed through. This supersedes the 0.4.0 note that section reads carry no signal (#42, #39).
20
+ - `COMPACT_FIELDS` is built from the SDK's `SUMMARY_FIELDS` plus explicit extras (`created_at`, `url`), with a test that derives from the SDK and a literal pin of the final lists, so SDK drift fails CI. Served rows are byte-identical (#42, #39).
21
+ - Dependency: node-hudu 0.17.0 -> 0.18.0 (exact pin). Plan hash re-stamped to `9d110798...`; operation set unchanged (236).
22
+
3
23
  ## 0.4.0
4
24
 
5
25
  **Upgrading from 0.3.0:** `hudu_list` now returns compact summary rows by default. Callers that read custom `fields`, article `content` or layout `fields` from list rows must pass `expand: true`. Everything else is additive or a fix.
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.17.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
@@ -269,9 +274,9 @@ caller-named origins — a security property, not a deployment preference.
269
274
  ## Article bodies are Markdown (node-hudu 0.14+)
270
275
 
271
276
  `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:
277
+ asks for Markdown, so single-article bodies (`articles.get`, `create`, `update`, and
278
+ `articles.resolve` / `articles.getContext` only with `opts.expand: true`) are returned and accepted as Markdown. Hudu callouts (`> [!INFO]`), accordions, task
279
+ 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 stored form is what the SDK's `markdownToHtml` emits and Hudu stores it as sent, so it shows what the SDK writes, not what the Hudu editor produces natively. The read rule also accepts the `code-block-mermaid-source` class, which may be what the editor produces (unverified). A save made in the Hudu UI editor itself is not yet verified. Rows from `hudu_fetch_many`, `hudu_read` `articles.list`, `articles.search` and `articles.findBySlug` still carry stored HTML:
275
280
  those operations take no format. Rules a caller needs:
276
281
 
277
282
  - A write's `data.content` is parsed as Markdown unless it opens with block-level HTML (`<p>`, `<div>`,
@@ -281,11 +286,10 @@ those operations take no format. Rules a caller needs:
281
286
  (for example a `<video>`); send HTML to keep it. The SDK's loss guard runs on every write.
282
287
  - Prefer Markdown (or editor-native HTML): Hudu reshapes API-written HTML on the next UI save (h5/h6 flattened, `small`/`abbr`/`q`/`section`/`aside` unwrapped, `thead` removed, an unknown callout type becomes info).
283
288
  - `articles.getSection` / `articles.patchSection` read or edit one section as Markdown, with dry-run.
284
- `articles.getSection` carries no `markdown_lossy` signal (the SDK drops `content_loss` inside it, and a
285
- section is not the whole body); read the whole article with `articles.get` to learn whether it is lossy.
289
+ `articles.getSection` carries the same `markdown_lossy` / `markdown_lossy_codes` signal as `articles.get`;
290
+ it describes the whole article, so the loss may be in another section.
286
291
  - 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` / `resolve`) and
288
- `hudu_get_article_context` then set `markdown_lossy: true` (with `markdown_lossy_codes`, the SDK's
292
+ (`<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
293
  `diffArticleRoundTrip` content-loss codes) and add one sentence to the result text; fetch the stored
290
294
  HTML with `hudu_fetch_many` (`{items:[{resource:"articles", id}]}`) to see the full body. The read is one
291
295
  fetch: the SDK converts the body and reports the loss on the record (no second request); a caller's own
@@ -294,6 +298,10 @@ those operations take no format. Rules a caller needs:
294
298
  A body that converts to nothing (only `<video>`, `<input>`, `<p></p>`) reads as `content: ""` with
295
299
  `markdown_lossy_codes` containing `ROUNDTRIP_BODY_EMPTY` (it used to fail with `CONVERSION_EMPTY_OUTPUT`).
296
300
  The SDK fails a Markdown read with `CONFIG_ERROR` when the Markdown form exceeds 256 KiB.
301
+ - `articles.resolve`, `articles.getContext` and `hudu_get_article_context` carry no lossy signal in the default mode: the result
302
+ holds an article summary (no body), so there is nothing to convert. Only with `opts.expand: true` is the full record
303
+ returned (for getContext, as `article`) with a Markdown body, and then the same `markdown_lossy` /
304
+ `markdown_lossy_codes` signal applies.
297
305
 
298
306
  ## SDK behaviour (0.12.0)
299
307
 
@@ -334,9 +342,11 @@ Every client the server builds — the parent, the fail-closed placeholders and
334
342
  blindly.
335
343
  - `cache: 'recommended'` — fixed on: the
336
344
  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.
345
+ per client, so per scope. The write and delete clients have their own caches, so after every `hudu_write` /
346
+ `hudu_delete` attempt that is not a dry run (including a failed or timed-out one, which may have
347
+ 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
348
+ the same server process is fresh. A write made by another process or deployment can still be served
349
+ 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
350
  - `blockPrivateHosts` on the per-scope clients: the literal-IP SSRF floor for CALLER-named
341
351
  origins, fail-closed at client construction. It complements the `HUDU_ALLOWED_BASE_HOSTS`
342
352
  hostname matcher (which alone can express wildcard suffixes) and holds even in
@@ -371,7 +381,7 @@ pnpm run dev
371
381
 
372
382
  CI (`.github/workflows/ci.yml`) runs `typecheck`, `lint` and `test` on every push and pull request.
373
383
 
374
- `node-hudu` is pinned to `0.17.0`. The catalog hash test detects metadata drift; upgrades require
384
+ `node-hudu` is pinned to `0.19.0`. The catalog hash test detects metadata drift; upgrades require
375
385
  review of the client authority, operation enums and redaction regressions. Docker uses `pnpm install --frozen-lockfile`.
376
386
 
377
387
  ## Deployment
@@ -14,6 +14,8 @@ const RECORD_PATHS = {
14
14
  // resolve answers the record itself; the `{ value }` envelope is the helper form of the same read.
15
15
  'articles.resolve': [[], ['value']],
16
16
  'articles.getContext': [['article']],
17
+ // The SDK reports getSection loss for the WHOLE body (the loss may sit in another section).
18
+ 'articles.getSection': [[]],
17
19
  };
18
20
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
19
21
  const take = (node, path, codes) => {
@@ -43,4 +45,4 @@ export function takeContentLoss(operation, result) {
43
45
  return { result: stripped, codes: [...codes].sort() };
44
46
  }
45
47
  /** The one short sentence appended to the result text when a read lost content. */
46
- export const lossyNote = (codes) => ` markdown_lossy: true (${codes.join(', ')}): the Markdown body is incomplete; fetch the stored HTML with hudu_fetch_many {items:[{resource:"articles", id}]} to see all of it.`;
48
+ 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.`;
@@ -37,6 +37,10 @@ export function registerDispatchTools(server, ctx) {
37
37
  annotations: spec.annotations,
38
38
  }, async (args) => {
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);
@@ -72,6 +76,7 @@ export function registerDispatchTools(server, ctx) {
72
76
  opts.dryRun = dry_run;
73
77
  if (typeof confirm === 'string')
74
78
  opts.confirm = confirm;
79
+ dispatched = true;
75
80
  let result = await dispatchOperation(client, effect, operation, bag, opts);
76
81
  // After the SDK's own input validation, so bad input stays CONFIG_ERROR. The refusing
77
82
  // placeholder never dials, so nothing reached Hudu; the engine swallows its error into a
@@ -120,7 +125,7 @@ export function registerDispatchTools(server, ctx) {
120
125
  }
121
126
  const simulated = result !== null && typeof result === 'object' && result.simulated === true;
122
127
  // 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) : ''), {
128
+ return ok((simulated ? `Dry run: nothing was written (${operation}).` : `${operation} completed.`) + (lossy.length ? lossyNote(lossy, operation) : ''), {
124
129
  simulated,
125
130
  operation,
126
131
  result: result ?? null,
@@ -136,6 +141,21 @@ export function registerDispatchTools(server, ctx) {
136
141
  return searchErrorContent(err);
137
142
  return operation === RESOLVE_ANY_OPERATION ? resolveErrorContent(err) : errorContent(err);
138
143
  }
144
+ finally {
145
+ // Each effect has its own cached client, so a write never clears the read client's cache
146
+ // (node-hudu clears only the client it mutates through). Clear the read client of this scope
147
+ // after every real write or delete ATTEMPT, in `finally`: a timeout or 5xx may still have
148
+ // applied. A dry run (`dry_run` absent is forced to one by the SDK) sends no write, so it
149
+ // does not clear. clearCache never throws; the guard makes sure it cannot alter the result.
150
+ if (mode !== 'read' && dispatched && dry_run === false) {
151
+ try {
152
+ ctx.hudu.clearCache();
153
+ }
154
+ catch (e) {
155
+ log.warn('read cache clear failed after write', { operation, error: e instanceof Error ? e.message : String(e) });
156
+ }
157
+ }
158
+ }
139
159
  });
140
160
  }
141
161
  }
@@ -1,4 +1,5 @@
1
1
  import * as z from 'zod/v4';
2
+ import { SUMMARY_FIELDS } from 'node-hudu';
2
3
  import { getCapability } from 'node-hudu/capabilities';
3
4
  import { configError } from 'node-hudu/mcp';
4
5
  import { assertSecretReadPermitted, assertWritePermitted } from '../policy.js';
@@ -96,14 +97,28 @@ function assertNonSearchableListReadPermitted(operation, ctx) {
96
97
  assertSecretReadPermitted(operation, config, log);
97
98
  }
98
99
  /**
99
- * Compact row fields per resource: the node-hudu `*Summary` shapes plus `created_at`. The SDK exports no
100
- * runtime projection, so `test/tools/lists-compact.test.ts` only pins these lists to a literal copy: it forces a reviewer to revisit them on a node-hudu bump, it does not detect SDK drift itself. `hudu_list` projects each row onto these unless `expand: true`.
100
+ * Extras beyond the SDK's `SUMMARY_FIELDS` (the keys of the node-hudu `*Summary` shapes). Timestamps go last
101
+ * in the served row, so `updated_at` (an SDK key where present) is moved after these extras.
102
+ */
103
+ const COMPACT_EXTRAS = {
104
+ companies: ['created_at'],
105
+ articles: ['url', 'created_at'],
106
+ assets: ['created_at'],
107
+ asset_layouts: ['url', 'created_at', 'updated_at'],
108
+ };
109
+ const compactFields = (r) => {
110
+ const base = SUMMARY_FIELDS[r];
111
+ return [...base.filter((k) => k !== 'updated_at'), ...COMPACT_EXTRAS[r], ...(base.includes('updated_at') ? ['updated_at'] : [])];
112
+ };
113
+ /**
114
+ * Compact row fields per resource: `SUMMARY_FIELDS` plus `COMPACT_EXTRAS`. `test/tools/lists-compact.test.ts`
115
+ * derives them from the SDK and also pins the final lists literally. `hudu_list` projects each row onto these unless `expand: true`.
101
116
  */
102
117
  export const COMPACT_FIELDS = {
103
- companies: ['id', 'name', 'nickname', 'slug', 'website', 'phone_number', 'city', 'state', 'id_number', 'archived', 'url', 'created_at', 'updated_at'],
104
- articles: ['id', 'name', 'slug', 'company_id', 'folder_id', 'draft', 'enable_sharing', 'url', 'created_at', 'updated_at'],
105
- assets: ['id', 'name', 'company_id', 'company_name', 'asset_layout_id', 'primary_serial', 'asset_type', 'archived', 'url', 'created_at', 'updated_at'],
106
- asset_layouts: ['id', 'name', 'slug', 'active', 'icon', 'color', 'url', 'created_at', 'updated_at'],
118
+ companies: compactFields('companies'),
119
+ articles: compactFields('articles'),
120
+ assets: compactFields('assets'),
121
+ asset_layouts: compactFields('asset_layouts'),
107
122
  };
108
123
  /** Project rows onto the compact fields; a field the row lacks is omitted, not nulled. */
109
124
  export function compactRows(resource, rows) {
@@ -384,8 +384,9 @@ export function createMcpServerFactory(requested, log) {
384
384
  // `cache` is the SDK's `recommended` per-entity read-through TTL preset (1 h structural / 5 min mutable / 1 min
385
385
  // base). Caches are per
386
386
  // client, hence per scope — a write through the write or delete client does NOT clear the
387
- // read client's cache (separate instances), so a read after a mutation is TTL-bounded stale,
388
- // never unbounded.
387
+ // read client's cache (separate instances), so the dispatchers call the read client's
388
+ // `clearCache()` after every write or delete attempt (src/tools/dispatch.ts). A write from
389
+ // another process is still TTL-bounded stale.
389
390
  // `cooldown` is the 429 cooldown gate, always on; a
390
391
  // locally refused call answers with RateLimitError.notSent, which the error envelope
391
392
  // surfaces so a local refusal stays distinguishable from a vendor rejection.
package/dist/tools.js CHANGED
@@ -30,4 +30,4 @@ export { MAX_SCOPED_CLIENTS, ScopedClientCache, createMcpServerFactory } from '.
30
30
  * it against the installed `CATALOG_PLAN_HASH`, so the upgrade fails CI instead of quietly shipping
31
31
  * a tool surface that no longer describes the SDK behind it.
32
32
  */
33
- export const BUILT_AGAINST_PLAN_HASH = '1ad5e23b9c140e8f9cf60ac710dd508fc1109e86b9a0620b2d0925f16b11d7b8';
33
+ export const BUILT_AGAINST_PLAN_HASH = '9d11079847a9e2a8d73f4a075a4004b49f59624be5ffc8253636fc41cf677702';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-hudu",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@modelcontextprotocol/server": "^2.3.1",
46
- "node-hudu": "0.17.0",
46
+ "node-hudu": "0.19.0",
47
47
  "zod": "^4.6.7"
48
48
  },
49
49
  "devDependencies": {