iit-mcp-hudu 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ **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.
6
+
7
+ - Docs: `articles.getSection` carries no `markdown_lossy` signal (node-hudu drops `content_loss` inside `readSection`); the README now says so and a test pins it. Read the whole article with `articles.get` to learn whether a body is lossy.
8
+ - Behaviour change: `hudu_list` serves compact summary rows by default (the node-hudu `*Summary` field sets, plus `url` / `created_at` / `updated_at`) instead of full records, cutting a default page from ~49-88k to under 20k characters. `expand: true` returns the full records as before. Envelope, `page_size` default 25 and `asset_layouts` paging (no `page_size`) are unchanged. Callers that read custom `fields`, article `content` or layout `fields` from list rows must pass `expand: true` (issue #22).
9
+ - Fix: the 401 shape annotation is bound to the call (AsyncLocalStorage) instead of module-level state written per request, so under http a 401 never carries another concurrent request's credential anomalies (#16).
10
+ - Fix: a 401 is annotated only with the anomalies of the credential actually presented on that call (the request key wins over the server-held `HUDU_API_KEY`; the env key's anomalies join only when it is the one dialed), and `hudu_search`'s fully-refused branch now carries the same annotation as every other tool (#16).
11
+ - Fix: `hudu_search` and `hudu_read` `operations.searchKnowledge` / `operations.searchAcrossResources` now refuse a malformed `x-hudu-api-key` (`CREDENTIAL_MALFORMED`) and an origin outside the allow-list (the `AUTH_ERROR` envelope other tools return) before any request, instead of returning a successful 0-hit result (#32).
12
+ - Fix: the same search paths now refuse every other refusing client state (key with no origin, invalid base URL, keyless request) with the `AUTH_ERROR` envelope, and `hudu_fetch_many` / `hudu_read` `operations.fetchMany` return that top-level refusal for a whole-batch credential or origin refusal instead of `ok: 0, failed: 1` with per-item errors; a genuine per-item vendor failure still lands on its row. A keyless `hudu_search` is no longer a soft 0-hit result (#35).
13
+ - Dependency: node-hudu 0.16.0 -> 0.17.0. Plan hash re-stamped to `1ad5e23b...`; operation set unchanged (236).
14
+ - Markdown article reads take their loss signal from the SDK: node-hudu now attaches `content_loss` to the record on a Markdown read, so the server no longer forces HTML and converts and diffs the body itself. `markdown_lossy` / `markdown_lossy_codes` are unchanged; the per-record `content_loss` is lifted into them and not passed through.
15
+ - Behaviour change: inline `<svg>` and `<iframe>` now survive Markdown reads and writes (they were flagged lossy and writes over them were refused). A body that converts to nothing reads as an empty body flagged `ROUNDTRIP_BODY_EMPTY` instead of failing.
16
+ - Docs: decision record for per-user identity (`docs/decisions/per-user-identity.md`): do not build the Hudu account link now; Entra sign-in plus server-side per-user policy and audit instead.
17
+
3
18
  ## 0.3.0
4
19
 
5
20
  **Upgrading from 0.2.3: breaking changes.** (1) Article bodies through `hudu_read` / `hudu_write` / `hudu_delete` are Markdown by default. (2) `HUDU_CACHE_PRESET`, `HUDU_COOLDOWN`, `HUDU_TIMEOUT_MS` and `HUDU_SEARCH_MAX_INDEX_PAGES` are removed and a set one now fails boot. (3) `hudu_list_companies` / `_articles` / `_assets` / `_asset_layouts` are replaced by `hudu_list(resource)`. (4) Served tool definitions drop `_meta`. (5) The repository uses pnpm. The SDK is pinned exactly to node-hudu 0.16.0 (plan hash `fba0aa46...`); 0.15.0 was never published. `HUDU_READ_ONLY` is NOT removed: `true` is a hard override (see below). **UPGRADE HAZARD:** on 0.2.3 an UNSET `HUDU_READ_ONLY` meant read-only. Now only `HUDU_WRITE_POLICY` decides when it is unset, so these two configs, with `HUDU_READ_ONLY` unset, now give the model `hudu_write` / `hudu_delete`: (a) `HUDU_WRITE_POLICY=all`; (b) `HUDU_WRITE_POLICY=allow_list` with a non-empty `HUDU_WRITE_ALLOW`. Set `HUDU_READ_ONLY=true` to keep them read-only. The server logs a warning at boot in exactly this case.
package/README.md CHANGED
@@ -26,9 +26,12 @@ excluded by `HUDU_SEARCH_RESOURCES` is refused by the list tool too, before any
26
26
  `asset_layouts` is not searchable (the SDK declares no vendor text filter for it), so it follows the
27
27
  same reachability as the existing ungated `hudu_get_asset_layout` tool. The tool set is the SDK's
28
28
  `CORE_TOOLS` plus `hudu_list`, which is this server's own; a future SDK upgrade must not re-add the
29
- four old names.
29
+ four old names. Rows are compact summaries by default (id, name, key scalar fields, `url`,
30
+ `created_at`, `updated_at`; no custom-field values, article bodies or layout field definitions), which keeps a
31
+ 25-row assets page under 20,000 characters instead of ~88,000; pass `expand: true` for the full
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`).
30
33
 
31
- These tools run on node-hudu 0.16.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.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.
32
35
 
33
36
  `hudu_fetch_many` (node-hudu 0.12.0) fetches up to 20 single records across mixed resources in
34
37
  one call: each item is exactly `{ resource, id, fields? }` — one of the 22 bare-id resources, a
@@ -268,25 +271,29 @@ caller-named origins — a security property, not a deployment preference.
268
271
  `hudu_read` and `hudu_write` pass `contentFormat: 'markdown'` to the SDK, and `hudu_get_article_context`
269
272
  asks for Markdown, so single-article bodies (`articles.get`, `articles.getContext`, `articles.resolve`,
270
273
  `create`, `update`) are returned and accepted as Markdown. Hudu callouts (`> [!INFO]`), accordions, task
271
- lists and Mermaid fences (stored as the editor-native `<pre class="language-mermaid">` form) round-trip as the dialect documents. Rows from `hudu_list` (articles),
272
- `hudu_fetch_many`, `articles.list`, `articles.search` and `articles.findBySlug` still carry stored HTML:
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:
273
275
  those operations take no format. Rules a caller needs:
274
276
 
275
277
  - A write's `data.content` is parsed as Markdown unless it opens with block-level HTML (`<p>`, `<div>`,
276
278
  `<h1>`-`<h6>`, `<table>`, lists, `<pre>`, `<blockquote>`, `<hr>`, Hudu's `mce-accordion`), in which case
277
279
  it is sent as HTML. Re-read an article through `articles.get` before editing it.
278
280
  - A Markdown write is refused when the stored article holds something the dialect cannot express
279
- (for example an inline `<svg>`); send HTML to keep it. The SDK's loss guard runs on every write.
281
+ (for example a `<video>`); send HTML to keep it. The SDK's loss guard runs on every write.
280
282
  - 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).
281
283
  - `articles.getSection` / `articles.patchSection` read or edit one section as Markdown, with dry-run.
282
- - A Markdown read can be incomplete: anything the dialect cannot express (an inline `<svg>`, say) comes
283
- back as plain text. `hudu_read` (`articles.get` / `getContext` / `resolve`) and
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.
286
+ - 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
284
288
  `hudu_get_article_context` then set `markdown_lossy: true` (with `markdown_lossy_codes`, the SDK's
285
289
  `diffArticleRoundTrip` content-loss codes) and add one sentence to the result text; fetch the stored
286
290
  HTML with `hudu_fetch_many` (`{items:[{resource:"articles", id}]}`) to see the full body. The read is one
287
- fetch with HTML forced and converted locally (so there is no second request); a caller's own
291
+ fetch: the SDK converts the body and reports the loss on the record (no second request); a caller's own
288
292
  `opts.format: "html"` returns the stored HTML untouched with no flag. Callouts, accordions, task
289
- lists and Mermaid round-trip cleanly and are never flagged; a lossless read is unchanged.
293
+ lists, Mermaid, inline `<svg>` and `<iframe>` round-trip cleanly and are never flagged; a lossless read is unchanged.
294
+ A body that converts to nothing (only `<video>`, `<input>`, `<p></p>`) reads as `content: ""` with
295
+ `markdown_lossy_codes` containing `ROUNDTRIP_BODY_EMPTY` (it used to fail with `CONVERSION_EMPTY_OUTPUT`).
296
+ The SDK fails a Markdown read with `CONFIG_ERROR` when the Markdown form exceeds 256 KiB.
290
297
 
291
298
  ## SDK behaviour (0.12.0)
292
299
 
@@ -364,7 +371,7 @@ pnpm run dev
364
371
 
365
372
  CI (`.github/workflows/ci.yml`) runs `typecheck`, `lint` and `test` on every push and pull request.
366
373
 
367
- `node-hudu` is pinned to `0.16.0`. The catalog hash test detects metadata drift; upgrades require
374
+ `node-hudu` is pinned to `0.17.0`. The catalog hash test detects metadata drift; upgrades require
368
375
  review of the client authority, operation enums and redaction regressions. Docker uses `pnpm install --frozen-lockfile`.
369
376
 
370
377
  ## Deployment
@@ -1,61 +1,46 @@
1
1
  /**
2
- * article-loss.ts — Markdown article reads, and the signal that one is incomplete.
2
+ * article-loss.ts — the signal that a Markdown article read is incomplete.
3
3
  *
4
- * Reads return article bodies as Markdown (node-hudu 0.14 dialect); anything Markdown cannot
5
- * express (an inline <svg>, say) would come back as plain text with no signal, because the SDK keeps
6
- * loss reporting out of the converter and documents `diffArticleRoundTrip` as the one vocabulary.
7
- *
8
- * So the read is made ONCE with `format: 'html'` forced, and the conversion the SDK's own
9
- * `projectContent` would have done (`htmlToMarkdown`) is done here instead, next to the diff of the
10
- * stored HTML against `markdownToHtml(htmlToMarkdown(html))`. Always one wire request: no second
11
- * dispatch, no cache or TTL dependence, no extra 429/cooldown exposure, no read-twice race. Only
12
- * `impact: 'content'` findings count (presentation-only changes are not loss); the constructs the
13
- * dialect models (callouts, accordions, task lists, Mermaid) round-trip clean. A caller's own
14
- * `opts.format: 'html'` is honoured untouched. A conversion failure (empty output, over the size
15
- * cap) throws the same error the SDK's conversion threw.
4
+ * Since node-hudu 0.17 a Markdown read reports its own loss: the SDK attaches `content_loss` (the
5
+ * `ROUNDTRIP_*` findings with `impact: 'content'`) to the article record when the stored body does
6
+ * not survive HTML -> Markdown -> HTML, and returns an empty body plus `ROUNDTRIP_BODY_EMPTY`
7
+ * instead of throwing when nothing converts. This lifts those codes out of the result into the
8
+ * tool's `markdown_lossy` fields and drops the per-record findings, so the result shape is the
9
+ * same as before. A caller's own `opts.format: 'html'` gets no `content_loss`, hence no flag.
16
10
  */
17
- import { diffArticleRoundTrip } from 'node-hudu';
18
- import { htmlToMarkdown, markdownToHtml } from 'node-hudu/content';
19
- /** The reads whose article body is converted, and where `content` sits in the SDK result. */
20
- const CONTENT_PATHS = {
21
- 'articles.get': [['content']],
11
+ /** The reads whose article body is converted, and where the article record sits in the SDK result. */
12
+ const RECORD_PATHS = {
13
+ 'articles.get': [[]],
22
14
  // resolve answers the record itself; the `{ value }` envelope is the helper form of the same read.
23
- 'articles.resolve': [['content'], ['value', 'content']],
24
- 'articles.getContext': [['article', 'content']],
15
+ 'articles.resolve': [[], ['value']],
16
+ 'articles.getContext': [['article']],
25
17
  };
26
- export const MARKDOWN_READ_OPERATIONS = new Set(Object.keys(CONTENT_PATHS));
27
18
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
28
- /** Whether the caller asked for HTML itself (then the read is returned as is, with no flag). */
29
- export const callerWantsHtml = (input) => isObject(input) && isObject(input.opts) && input.opts.format === 'html';
30
- /** `input` with `opts.format` forced to 'html', merged over the caller's own opts. */
31
- export function forceHtml(input) {
32
- return { ...input, opts: { ...(isObject(input.opts) ? input.opts : {}), format: 'html' } };
33
- }
34
- const convert = (node, path, codes) => {
19
+ const take = (node, path, codes) => {
35
20
  if (!isObject(node))
36
21
  return node;
37
22
  const [key, ...rest] = path;
38
- const value = node[key];
39
- if (rest.length === 0) {
40
- if (typeof value !== 'string')
23
+ if (key === undefined) {
24
+ if (!Array.isArray(node.content_loss))
41
25
  return node;
42
- const markdown = htmlToMarkdown(value);
43
- for (const f of diffArticleRoundTrip(value, markdownToHtml(markdown)))
44
- if (f.impact === 'content')
26
+ for (const f of node.content_loss)
27
+ if (typeof f?.code === 'string')
45
28
  codes.add(f.code);
46
- return { ...node, [key]: markdown };
29
+ const record = { ...node };
30
+ delete record.content_loss;
31
+ return record;
47
32
  }
48
- const inner = convert(value, rest, codes);
49
- return inner === value ? node : { ...node, [key]: inner };
33
+ const inner = take(node[key], rest, codes);
34
+ return inner === node[key] ? node : { ...node, [key]: inner };
50
35
  };
51
- /** The Markdown result for `operation` from its stored-HTML result, plus the content-loss codes (empty when lossless). */
52
- export function projectMarkdown(operation, htmlResult) {
53
- const paths = CONTENT_PATHS[operation];
36
+ /** `result` without the SDK's `content_loss`, plus its codes (empty when the read lost nothing). */
37
+ export function takeContentLoss(operation, result) {
38
+ const paths = RECORD_PATHS[operation];
54
39
  if (paths === undefined)
55
- return { result: htmlResult, codes: [] };
40
+ return { result, codes: [] };
56
41
  const codes = new Set();
57
- const result = paths.reduce((node, path) => convert(node, path, codes), htmlResult);
58
- return { result, codes: [...codes].sort() };
42
+ const stripped = paths.reduce((node, path) => take(node, path, codes), result);
43
+ return { result: stripped, codes: [...codes].sort() };
59
44
  }
60
45
  /** The one short sentence appended to the result text when a read lost content. */
61
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.`;
@@ -56,7 +56,7 @@ export const DEDICATED_READ_TOOLS = {
56
56
  hudu_list: {
57
57
  // The registry operations `hudu_list(resource)` serves, keyed to the resource it takes.
58
58
  covers: ['companies.list', 'articles.list', 'assets.listAcrossCompanies', 'asset_layouts.list'],
59
- toolArguments: ['resource', 'page', 'page_size', 'filters'],
59
+ toolArguments: ['resource', 'page', 'page_size', 'expand', 'filters'],
60
60
  },
61
61
  hudu_fetch_many: {
62
62
  covers: ['operations.fetchMany'],
@@ -213,6 +213,7 @@ function listInputSchema(operation) {
213
213
  field('resource', 'string', true, { enum: [resource] }),
214
214
  field('page', 'integer', false),
215
215
  ...(LIST_RESOURCES[resource].pageSize ? [field('page_size', 'integer', false)] : []),
216
+ field('expand', 'boolean', false),
216
217
  field('filters', 'object', false, { description: `Accepted keys: ${listFilterNames(resource).join(', ')}.` }),
217
218
  ]);
218
219
  }
@@ -3,8 +3,8 @@ 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 { MARKDOWN_READ_OPERATIONS, callerWantsHtml, forceHtml, lossyNote, projectMarkdown } from './article-loss.js';
7
- import { ARTICLE_FORMAT_NOTE, contextErrorContent, errorContent, createResultBuilders, resolveErrorContent, searchErrorContent, unauthorizedContent } from './results.js';
6
+ import { lossyNote, takeContentLoss } from './article-loss.js';
7
+ import { ARTICLE_FORMAT_NOTE, contextErrorContent, errorContent, createResultBuilders, resolveErrorContent, searchErrorContent, unauthorizedContent, upfrontRefusal } from './results.js';
8
8
  import { INVOKE_OUTPUT } from './schemas.js';
9
9
  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
10
  /** The SDK's curated text cites its own tracker ("(issues #70/#72)"); that is noise to a model and an internal reference. */
@@ -72,12 +72,22 @@ export function registerDispatchTools(server, ctx) {
72
72
  opts.dryRun = dry_run;
73
73
  if (typeof confirm === 'string')
74
74
  opts.confirm = confirm;
75
- // Markdown article reads: ONE fetch with HTML forced, converted (and loss-checked) locally.
76
- const markdownRead = mode === 'read' && MARKDOWN_READ_OPERATIONS.has(operation) && !callerWantsHtml(bag);
77
- let result = await dispatchOperation(client, effect, operation, markdownRead ? forceHtml(bag) : bag, opts);
75
+ let result = await dispatchOperation(client, effect, operation, bag, opts);
76
+ // After the SDK's own input validation, so bad input stays CONFIG_ERROR. The refusing
77
+ // placeholder never dials, so nothing reached Hudu; the engine swallows its error into a
78
+ // 0-hit result (searchKnowledge, an isolating searchAcrossResources) or per-item rows
79
+ // (fetchMany), so only those answer the typed refusal (a throwing call already carries the SDK's own envelope).
80
+ const isolating = operation === SEARCH_ACROSS_OPERATION && bag.opts?.isolateErrors === true;
81
+ // fetchMany folds a whole-batch refusal into per-item rows; a refused client is a top-level error.
82
+ if (operation === SEARCH_KNOWLEDGE_OPERATION || operation === FETCH_MANY_OPERATION || isolating) {
83
+ const refused = upfrontRefusal();
84
+ if (refused !== undefined)
85
+ return refused;
86
+ }
87
+ // A Markdown article read that lost content carries the SDK's content_loss (see article-loss.ts).
78
88
  let lossy = [];
79
- if (markdownRead)
80
- ({ result, codes: lossy } = projectMarkdown(operation, result));
89
+ if (mode === 'read')
90
+ ({ result, codes: lossy } = takeContentLoss(operation, result));
81
91
  if (operation === 'companies.getContext') {
82
92
  result = redactCompanyContextCredentials(result, operation, config, log);
83
93
  }
@@ -1,5 +1,5 @@
1
1
  import * as z from 'zod/v4';
2
- import { ARTICLE_FORMAT_NOTE, errorContent, metaSpec, createResultBuilders } from './results.js';
2
+ import { ARTICLE_FORMAT_NOTE, 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.
@@ -52,6 +52,10 @@ export function registerFetchManyTool(server, ctx) {
52
52
  // The deployment's authority, asserted per item BEFORE any request — the same wrapper the
53
53
  // hudu_read dispatch of operations.fetchMany applies, so both paths gate identically.
54
54
  applyFetchManyPolicyToInvoke(ctx, { items: args.items });
55
+ // A refusing client fails every item alike: answer once, as every other tool does.
56
+ const refused = upfrontRefusal();
57
+ if (refused !== undefined)
58
+ return refused;
55
59
  const result = await ctx.ops.fetchMany(args.items);
56
60
  return ok(`${result.ok} of ${args.items.length} item(s) fetched${result.failed > 0 ? `; ${result.failed} failed — the typed per-item error is on each row` : ''}.`, { ok: result.ok, failed: result.failed, results: result.results });
57
61
  }
@@ -2,8 +2,8 @@ import * as z from 'zod/v4';
2
2
  import { getCapability } from 'node-hudu/capabilities';
3
3
  import { configError } from 'node-hudu/mcp';
4
4
  import { assertSecretReadPermitted, assertWritePermitted } from '../policy.js';
5
- import { ARTICLE_FORMAT_NOTE, errorContent, createResultBuilders } from './results.js';
6
- import { LIST_OUTPUT, LIST_PAGE, MAX_LIMIT, DEFAULT_LIMIT } from './schemas.js';
5
+ import { errorContent, createResultBuilders } from './results.js';
6
+ import { EXPAND, LIST_OUTPUT, LIST_PAGE, MAX_LIMIT, DEFAULT_LIMIT } from './schemas.js';
7
7
  /** The one dedicated list tool this module registers (exported so tests derive the served set). */
8
8
  export const LIST_TOOLS = ['hudu_list'];
9
9
  /** The tool names `hudu_list` replaced; a call to one gets a clear pointer instead of "not found". */
@@ -95,6 +95,24 @@ function assertNonSearchableListReadPermitted(operation, ctx) {
95
95
  assertWritePermitted(operation, getCapability(operation), config, log);
96
96
  assertSecretReadPermitted(operation, config, log);
97
97
  }
98
+ /**
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`.
101
+ */
102
+ 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'],
107
+ };
108
+ /** Project rows onto the compact fields; a field the row lacks is omitted, not nulled. */
109
+ export function compactRows(resource, rows) {
110
+ const keep = COMPACT_FIELDS[resource];
111
+ return rows.map((row) => {
112
+ const r = row;
113
+ return Object.fromEntries(keep.filter((k) => k in r).map((k) => [k, r[k]]));
114
+ });
115
+ }
98
116
  const S = z.string();
99
117
  const D = (t, d) => t.optional().describe(d);
100
118
  const ID = z.number().int().positive();
@@ -171,20 +189,22 @@ export function registerListTools(server, ctx) {
171
189
  description: 'List ONE bounded page of companies, articles, assets (across all companies) or asset_layouts, backed by the fixed operations companies.list / articles.list / assets.listAcrossCompanies / asset_layouts.list. Returns at most page_size rows plus whether more exist; it never pages through the tenant. This tool takes no operation argument and cannot be redirected to a write. ' +
172
190
  'Filters go in `filters`; each resource accepts only its own: ' +
173
191
  LIST_RESOURCE_NAMES.map((r) => `${r}: ${listFilterNames(r).join(', ')}`).join('; ') +
174
- '. A filter the resource does not accept is refused naming the accepted ones. asset_layouts takes no page_size (the vendor ignores it): a page is the vendor’s own size, returned whole, has_more is conservative (true for any non-empty page), stop when a page comes back empty. ' +
192
+ '. Rows are compact summaries (id, name, scalars, url, created_at, updated_at; no custom fields/bodies); expand: true: full records. A filter the resource does not accept is refused naming the accepted ones. asset_layouts takes no page_size (the vendor ignores it): a page is the vendor’s own size, returned whole, has_more is conservative (true for any non-empty page), stop when a page comes back empty. ' +
175
193
  'Prefer hudu_search or hudu_resolve_any to identify one record, and the *_context / hudu_get_asset_layout reads to read one in full. This is the ungated path for these reads; use it rather than the approval-gated hudu_read / hudu_write / hudu_delete.' +
176
- ARTICLE_FORMAT_NOTE,
194
+ ' Article bodies: compact article rows carry none; expand: true returns stored HTML (hudu_get_article_context reads Markdown).',
177
195
  // strict: a stray top-level filter (the 0.2.x flat shape) must be refused, never stripped into an account-wide read.
178
196
  inputSchema: z.strictObject({
179
197
  resource: z.enum(LIST_RESOURCE_NAMES).describe('Which collection to list.'),
180
198
  page: LIST_PAGE,
181
199
  page_size: z.number().int().min(1).max(MAX_LIMIT).optional().describe(`Rows per page (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}); refused for asset_layouts, never clamped.`),
200
+ expand: EXPAND,
182
201
  filters: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional().describe('Resource-specific filters (see the tool description).'),
183
202
  }),
184
203
  outputSchema: LIST_OUTPUT,
185
204
  annotations: { readOnlyHint: true, openWorldHint: true },
186
205
  }, async (args) => {
187
- const { resource, page, page_size, filters } = args;
206
+ const { resource, page, page_size, filters, expand } = args;
207
+ const shape = (rows) => (expand ? rows : compactRows(resource, rows));
188
208
  try {
189
209
  const spec = LIST_RESOURCES[resource];
190
210
  const operation = spec.operation;
@@ -206,14 +226,14 @@ export function registerListTools(server, ctx) {
206
226
  const answered = await firstPage(pages(resource, { ...parsed.data, page }));
207
227
  if (answered === null)
208
228
  return ok('No asset layouts matched.', { rows: [], page, page_size: 0, has_more: false, count: 0 });
209
- return ok(`${answered.items.length} asset layout row(s) on page ${page}; ${answered.hasMore ? 'more pages may exist' : 'no more pages'}.`, { rows: answered.items, page, page_size: answered.page_size, has_more: answered.hasMore, count: answered.items.length });
229
+ return ok(`${answered.items.length} asset layout row(s) on page ${page}; ${answered.hasMore ? 'more pages may exist' : 'no more pages'}.`, { rows: shape(answered.items), page, page_size: answered.page_size, has_more: answered.hasMore, count: answered.items.length });
210
230
  }
211
231
  const size = page_size ?? DEFAULT_LIMIT;
212
232
  const answered = await firstPage(pages(resource, { ...parsed.data, page, page_size: size }));
213
233
  if (answered === null)
214
234
  return ok(`No ${resource} matched.`, { rows: [], page, page_size: size, has_more: false, count: 0 });
215
235
  const bounded = boundPage(answered, size, page, isNonPaginated(operation));
216
- return ok(`${bounded.rows.length} ${spec.label} row(s) on page ${page} (page_size ${size}); ${bounded.hasMore ? 'more pages exist' : 'no more pages'}${bounded.note}.`, { rows: bounded.rows, page, page_size: answered.page_size, has_more: bounded.hasMore, count: bounded.rows.length });
236
+ return ok(`${bounded.rows.length} ${spec.label} row(s) on page ${page} (page_size ${size}); ${bounded.hasMore ? 'more pages exist' : 'no more pages'}${bounded.note}.`, { rows: shape(bounded.rows), page, page_size: answered.page_size, has_more: bounded.hasMore, count: bounded.rows.length });
217
237
  }
218
238
  catch (err) {
219
239
  return errorContent(err);
@@ -2,7 +2,7 @@ import * as z from 'zod/v4';
2
2
  import { TOOL_DESCRIPTIONS } from 'node-hudu/mcp';
3
3
  import { assertSecretReadPermitted } from '../policy.js';
4
4
  import { redactCompanyContextCredentials } from './company-context-policy.js';
5
- import { lossyNote, projectMarkdown } from './article-loss.js';
5
+ import { lossyNote, takeContentLoss } from './article-loss.js';
6
6
  import { contextErrorContent, errorContent, createResultBuilders, resolveErrorContent } from './results.js';
7
7
  import { ASSET_IDENTIFIER, CONTEXT_OUTPUT, DEFAULT_LIMIT, EXPAND, HITS_OUTPUT, IDENTIFIER, LIMIT, ONE_OUTPUT, resourceEnum } from './schemas.js';
8
8
  /** Register the eleven CORE read tools after `hudu_search`, in `CORE_TOOLS` order. */
@@ -110,11 +110,11 @@ export function registerReadTools(server, ctx) {
110
110
  annotations: { readOnlyHint: true, openWorldHint: true },
111
111
  }, async (args) => {
112
112
  try {
113
- // ONE fetch with HTML forced; converted and loss-checked locally (see article-loss.ts).
113
+ // The SDK converts and reports loss on the record (see article-loss.ts).
114
114
  const stored = args.opts?.expand
115
- ? await hudu.articles.getContext(args.id, { expand: true, format: 'html' })
116
- : await hudu.articles.getContext(args.id, { format: 'html' });
117
- const { result: context, codes: lossy } = projectMarkdown('articles.getContext', stored);
115
+ ? await hudu.articles.getContext(args.id, { expand: true, format: 'markdown' })
116
+ : await hudu.articles.getContext(args.id, { format: 'markdown' });
117
+ const { result: context, codes: lossy } = takeContentLoss('articles.getContext', stored);
118
118
  return ok('Article context returned.' + (lossy.length ? lossyNote(lossy) : ''), {
119
119
  context,
120
120
  ...(lossy.length ? { markdown_lossy: true, markdown_lossy_codes: lossy } : {}),
@@ -1,20 +1,31 @@
1
1
  /**
2
2
  * tools/results.ts — the `ToolReturn` shape every handler answers with, and its builders.
3
3
  */
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
5
  import { redact } from 'node-hudu';
5
6
  import { META_TOOLS } from 'node-hudu/mcp';
6
7
  import { normalizeResultUrls } from './result-urls.js';
7
8
  import { AUTH_FAILURE_CODES } from './search-policy.js';
8
9
  import { CREDENTIAL_LOAD_HINT } from '../credential.js';
9
- let credentialShapeView = {};
10
- /** Publish (or clear) this serving unit's view of the credentials it dials with. */
11
- export function setCredentialShapeView(view) {
12
- credentialShapeView = view;
10
+ const viewScope = new AsyncLocalStorage();
11
+ /** Run `fn` (and everything it awaits) with `view` as the credential view `errorContent` reads. */
12
+ export function runWithCredentialShapeView(view, fn) {
13
+ return viewScope.run(view, fn);
14
+ }
15
+ /**
16
+ * The refusal envelope for a unit whose client is a refusing placeholder, else undefined. The
17
+ * search and fetchMany paths fold a refusal into a 0-hit result or per-item rows, so they ask this
18
+ * first and answer exactly as every other tool does, before any request.
19
+ */
20
+ export function upfrontRefusal() {
21
+ const refusal = viewScope.getStore()?.refusal;
22
+ return refusal === undefined ? undefined : errorContent(refusal);
13
23
  }
14
24
  /** The anomalies from the credentials the CURRENT serving unit presented, if any were seen. */
15
25
  function observedCredentialAnomalies() {
16
- const env = credentialShapeView.env ?? [];
17
- const request = credentialShapeView.request ?? [];
26
+ const view = viewScope.getStore() ?? {};
27
+ const env = view.env ?? [];
28
+ const request = view.request ?? [];
18
29
  const both = [...env, ...request];
19
30
  return [...new Set(both)];
20
31
  }
@@ -252,8 +263,12 @@ export function credentialMalformedContent(anomalies) {
252
263
  * (node-hudu 0.13.2), so its view is the first evidence the auth branch consults; the SDK's own
253
264
  * 401 diagnostic is consulted next ({@link unauthorizedShapeDiagnosticContent}). With no anomalies
254
265
  * and no SDK artifact diagnostic the envelope is exactly the legacy shape every existing test pins.
266
+ *
267
+ * Omitted, `anomalies` defaults to the current serving unit's view, so a caller that answers a
268
+ * credential refusal without an error to hand (the search branches, where the engine isolates the
269
+ * 401s into `meta.failed`) is annotated exactly like the thrown path.
255
270
  */
256
- export function unauthorizedContent(anomalies = []) {
271
+ export function unauthorizedContent(anomalies = observedCredentialAnomalies()) {
257
272
  if (anomalies.length > 0) {
258
273
  return {
259
274
  content: [
@@ -1,6 +1,6 @@
1
1
  import * as z from 'zod/v4';
2
2
  import { SEARCH_HELP, SEARCH_MODES, SEARCH_RESOURCES, configError } from 'node-hudu/mcp';
3
- import { errorContent, createResultBuilders, searchErrorContent, unauthorizedContent } from './results.js';
3
+ import { errorContent, createResultBuilders, searchErrorContent, unauthorizedContent, upfrontRefusal } from './results.js';
4
4
  import { HELP_TOPICS, SEARCH_OUTPUT, resourceEnum } from './schemas.js';
5
5
  import { credentialRefused, sanitizeIndexMeta, sanitizeSearchFailures } from './search-policy.js';
6
6
  import { adjustHelpText, adjustedSearchModes, searchQueryDescription, searchToolDescription } from './search-prose.js';
@@ -129,6 +129,9 @@ export function registerSearchTool(server, ctx) {
129
129
  return errorContent(configError('hudu_search: the body index is unavailable because HUDU_SEARCH_RESOURCES excludes articles or assets; its walk is corpus-wide. Use tier:"vendor" without refresh, or restore the corpus resources.'));
130
130
  }
131
131
  try {
132
+ const refused = upfrontRefusal();
133
+ if (refused !== undefined)
134
+ return refused;
132
135
  const result = await ops.searchKnowledge(args.query, {
133
136
  // An omitted `resources` resolves to the deployment's default scope, never to the SDK's
134
137
  // articles+assets default when those are outside `searchableResources`.
@@ -12,7 +12,7 @@ import { applyReadOnly } from '../config.js';
12
12
  import { headerRecord, readHuduBase, readHuduKey } from '../auth.js';
13
13
  import { baseHostAllowed, originOf } from '../base-hosts.js';
14
14
  import { CREDENTIAL_LOAD_HINT, detectCredentialShape } from '../credential.js';
15
- import { setCredentialShapeView } from './results.js';
15
+ import { runWithCredentialShapeView } from './results.js';
16
16
  import { registerTools } from './register.js';
17
17
  import { answerRenamedTools } from './renamed.js';
18
18
  import { slimToolSchemas } from './slim.js';
@@ -107,14 +107,22 @@ function malformedCredentialStrategy(anomalies) {
107
107
  // parsing.
108
108
  name: 'x-hudu-api-key malformed',
109
109
  async headers() {
110
- const refusal = new AuthError('this request presented a credential whose shape is malformed; nothing was dialed', {
111
- suggestedAction: CREDENTIAL_LOAD_HINT,
112
- });
113
- refusal.anomalies = anomalies;
114
- throw refusal;
110
+ throw malformedCredentialRefusal(anomalies);
115
111
  },
116
112
  };
117
113
  }
114
+ /** The typed refusal a malformed credential answers with; `errorContent` maps it to CREDENTIAL_MALFORMED. */
115
+ function malformedCredentialRefusal(anomalies) {
116
+ const refusal = new AuthError('this request presented a credential whose shape is malformed; nothing was dialed', {
117
+ suggestedAction: CREDENTIAL_LOAD_HINT,
118
+ });
119
+ refusal.anomalies = anomalies;
120
+ return refusal;
121
+ }
122
+ /** The error a refusing placeholder client's strategy surfaces, as `errorContent` renders it for every other tool. */
123
+ function placeholderRefusal(strategy) {
124
+ return new AuthError(`Auth strategy "${strategy.name}" did not produce usable credential headers; no request was sent`);
125
+ }
118
126
  /**
119
127
  * A per-(origin, credential) client built from the request's own `x-hudu-base-url` and
120
128
  * `x-hudu-api-key`, cached by the pair's hash.
@@ -169,6 +177,40 @@ export class ScopedClientCache {
169
177
  return scoped;
170
178
  }
171
179
  }
180
+ /** A header value that is present and non-blank; anything else is treated as not sent. */
181
+ const usable = (v) => (v === undefined || v.trim().length === 0 ? undefined : v);
182
+ /**
183
+ * Which credential an http request presents: the usable header key and base, and whether the
184
+ * origin is caller-named. The request key wins; the server-held `HUDU_API_KEY` is dialed only when
185
+ * no header key came and the origin is not caller-named. One rule, shared by the dispatcher
186
+ * ({@link resolveHuduClient}) and the 401 shape view ({@link buildCredentialShapeView}).
187
+ */
188
+ function presentedCredential(config, headers) {
189
+ const headerKey = usable(headers === undefined ? undefined : readHuduKey(headers));
190
+ const headerBase = usable(headers === undefined ? undefined : readHuduBase(headers));
191
+ const defaultOrigin = config.HUDU_BASE_URL === undefined ? undefined : originOf(config.HUDU_BASE_URL);
192
+ const headerOrigin = headerBase === undefined ? undefined : originOf(headerBase);
193
+ // An unparsable header value is caller-named even when there is no default to differ from: two
194
+ // `undefined` origins must not read as "the default", or the default key would be paired with it.
195
+ const callerNamed = headerBase !== undefined && (headerOrigin === undefined || headerOrigin !== defaultOrigin);
196
+ return { headerKey, headerBase, callerNamed, envKeyPresented: headerKey === undefined && !callerNamed };
197
+ }
198
+ /**
199
+ * The credential anomalies a 401 on this serving unit may be annotated with: ONLY those of the
200
+ * credential actually presented. Under stdio that is the env key; under http the request key wins,
201
+ * and the env key's anomalies join only when it is the one that would be dialed. (A malformed key
202
+ * from either source is refused pre-dial, so a real 401 is almost always clean-shape.)
203
+ */
204
+ export function buildCredentialShapeView(config, headers) {
205
+ const anomaliesOf = (k) => {
206
+ const a = k === undefined ? [] : detectCredentialShape(k);
207
+ return a.length > 0 ? a : undefined;
208
+ };
209
+ if (config.MCP_TRANSPORT === 'stdio')
210
+ return { env: anomaliesOf(config.HUDU_API_KEY) };
211
+ const { headerKey, envKeyPresented } = presentedCredential(config, headers);
212
+ return { env: envKeyPresented ? anomaliesOf(config.HUDU_API_KEY) : undefined, request: anomaliesOf(headerKey) };
213
+ }
172
214
  /**
173
215
  * The client a request's tools should use.
174
216
  *
@@ -194,31 +236,25 @@ export class ScopedClientCache {
194
236
  * Under `stdio` the process boundary is the trust boundary and the parent already carries the
195
237
  * environment's credential and origin; the headers are never read on this path.
196
238
  */
197
- function resolveHuduClient(entry, config, ctx, log) {
239
+ export function resolveHuduClient(entry, config, ctx, log) {
198
240
  if (config.MCP_TRANSPORT === 'stdio')
199
241
  return { client: entry.parent, origin: new URL(config.HUDU_BASE_URL).origin };
200
242
  const headers = ctx.requestInfo === undefined ? undefined : headerRecord(ctx.requestInfo.headers);
201
- const usable = (v) => (v === undefined || v.trim().length === 0 ? undefined : v);
202
- const headerKey = usable(headers === undefined ? undefined : readHuduKey(headers));
203
- const headerBase = usable(headers === undefined ? undefined : readHuduBase(headers));
243
+ const { headerKey, headerBase, callerNamed } = presentedCredential(config, headers);
204
244
  const defaultBase = config.HUDU_BASE_URL;
205
245
  const base = headerBase ?? defaultBase;
206
246
  // Only a caller-named origin is subject to the allow-list, and naming the default one is free.
207
- const defaultOrigin = defaultBase === undefined ? undefined : originOf(defaultBase);
208
- const headerOrigin = headerBase === undefined ? undefined : originOf(headerBase);
209
- // An unparsable header value is caller-named even when there is no default to differ from: two
210
- // `undefined` origins must not read as "the default", or the default key would be paired with it.
211
- const callerNamed = headerBase !== undefined && (headerOrigin === undefined || headerOrigin !== defaultOrigin);
212
247
  // Keep `undefined` distinct from an explicit list in config: with a configured default, an
213
248
  // omitted list means default-only; without one it preserves documented BYO-origin mode.
214
249
  const callerOriginAllowed = config.HUDU_ALLOWED_BASE_HOSTS === undefined
215
250
  ? defaultBase === undefined
216
251
  : baseHostAllowed(headerBase, config.HUDU_ALLOWED_BASE_HOSTS);
217
- if (callerNamed && !callerOriginAllowed)
218
- return { client: entry.baseRefused };
252
+ if (callerNamed && !callerOriginAllowed) {
253
+ return { client: entry.baseRefused, refusal: placeholderRefusal(BASE_URL_NOT_ALLOWED) };
254
+ }
219
255
  const key = headerKey ?? (callerNamed ? undefined : config.HUDU_API_KEY);
220
256
  if (key === undefined)
221
- return { client: entry.parent };
257
+ return { client: entry.parent, refusal: placeholderRefusal(NO_SERVER_CREDENTIAL) };
222
258
  // Pre-dispatch shape gate, on the value this request will actually present: a credential whose
223
259
  // bytes were mangled by the load is refused HERE, before a client is constructed and so before
224
260
  // the first dial. The check is on whichever value won above, so it covers the per-request header
@@ -239,10 +275,10 @@ function resolveHuduClient(entry, config, ctx, log) {
239
275
  client = entry.buildPlaceholder(malformedCredentialStrategy(anomalies));
240
276
  entry.malformed.set(id, client);
241
277
  }
242
- return { client };
278
+ return { client, refusal: malformedCredentialRefusal(anomalies) };
243
279
  }
244
280
  if (base === undefined)
245
- return { client: entry.noBase };
281
+ return { client: entry.noBase, refusal: placeholderRefusal(NO_REQUEST_BASE_URL) };
246
282
  try {
247
283
  const client = entry.scopes.for(base, key);
248
284
  // Only publish an origin after the dispatched client has passed SDK validation.
@@ -263,9 +299,9 @@ function resolveHuduClient(entry, config, ctx, log) {
263
299
  client = entry.buildPlaceholder(malformedCredentialStrategy([]));
264
300
  entry.malformed.set(id, client);
265
301
  }
266
- return { client };
302
+ return { client, refusal: malformedCredentialRefusal([]) };
267
303
  }
268
- return { client: entry.badBase };
304
+ return { client: entry.badBase, refusal: placeholderRefusal(INVALID_REQUEST_BASE_URL) };
269
305
  }
270
306
  throw err;
271
307
  }
@@ -413,29 +449,19 @@ export function createMcpServerFactory(requested, log) {
413
449
  };
414
450
  const read = build('read');
415
451
  const mutations = config.HUDU_WRITE_POLICY === 'deny' ? undefined : { write: build('write'), delete: build('delete') };
416
- // The server-held credential's anomalies, computed once: the auth-failure envelope annotates a
417
- // refusal with them so a 401 on a value this server already knows is malformed is diagnosable
418
- // without another dial. `undefined` when the value is clean, so nothing is added to the envelope.
419
- const envAnomalies = config.HUDU_API_KEY === undefined ? undefined : detectCredentialShape(config.HUDU_API_KEY);
420
- const envView = envAnomalies !== undefined && envAnomalies.length > 0 ? envAnomalies : undefined;
421
452
  return (ctx) => {
422
453
  const resolve = (entry) => resolveHuduClient(entry, config, ctx, log);
423
- // Per serving unit (one stdio connection, or one HTTP request): which of the credentials this
424
- // unit is about to present look malformed. Published before the first tool can fail, so the
425
- // auth-failure branch can answer with it. A clean value contributes nothing.
426
- const headerKey = config.MCP_TRANSPORT === 'stdio' || ctx.requestInfo === undefined
427
- ? undefined
428
- : readHuduKey(headerRecord(ctx.requestInfo.headers));
429
- const requestAnomalies = headerKey === undefined ? undefined : detectCredentialShape(headerKey);
430
- setCredentialShapeView({
431
- env: envView,
432
- request: requestAnomalies !== undefined && requestAnomalies.length > 0 ? requestAnomalies : undefined,
433
- });
434
- const { client: hudu, origin: huduOrigin } = resolve(read);
454
+ // Per serving unit (one stdio connection, or one HTTP request): the anomalies of the credential
455
+ // this unit presents, published before the first tool can fail (see buildCredentialShapeView).
456
+ const shapeView = buildCredentialShapeView(config, ctx.requestInfo === undefined ? undefined : headerRecord(ctx.requestInfo.headers));
457
+ const { client: hudu, origin: huduOrigin, refusal } = resolve(read);
435
458
  const mutationClients = mutations ? { write: resolve(mutations.write).client, delete: resolve(mutations.delete).client } : undefined;
436
459
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION, title: 'Hudu MCP Server' });
437
460
  answerRenamedTools(server);
438
461
  slimToolSchemas(server);
462
+ // Every handler registered below runs inside this request's view scope (see results.ts).
463
+ const register = server.registerTool.bind(server);
464
+ server.registerTool = (name, config, cb) => register(name, config, (...a) => runWithCredentialShapeView(refusal === undefined ? shapeView : { ...shapeView, refusal }, () => cb(...a)));
439
465
  registerTools(server, { hudu, huduOrigin, config, log, ...(mutationClients ? { mutationClients } : {}) });
440
466
  return server;
441
467
  };
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 = 'fba0aa4600d095e3a68e3cb25b55e78a9b9023be1f3b4e6f78fb8644e847ed2d';
33
+ export const BUILT_AGAINST_PLAN_HASH = '1ad5e23b9c140e8f9cf60ac710dd508fc1109e86b9a0620b2d0925f16b11d7b8';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iit-mcp-hudu",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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.16.0",
46
+ "node-hudu": "0.17.0",
47
47
  "zod": "^4.6.7"
48
48
  },
49
49
  "devDependencies": {