iit-mcp-hudu 0.1.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.
Files changed (54) hide show
  1. package/.env.example +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +233 -0
  4. package/dist/auth.d.ts +51 -0
  5. package/dist/auth.js +84 -0
  6. package/dist/base-hosts.d.ts +20 -0
  7. package/dist/base-hosts.js +39 -0
  8. package/dist/config.d.ts +86 -0
  9. package/dist/config.js +323 -0
  10. package/dist/http.d.ts +45 -0
  11. package/dist/http.js +129 -0
  12. package/dist/lifecycle.d.ts +29 -0
  13. package/dist/lifecycle.js +52 -0
  14. package/dist/logger.d.ts +24 -0
  15. package/dist/logger.js +26 -0
  16. package/dist/main.d.ts +2 -0
  17. package/dist/main.js +56 -0
  18. package/dist/policy.d.ts +79 -0
  19. package/dist/policy.js +175 -0
  20. package/dist/stdio.d.ts +21 -0
  21. package/dist/stdio.js +29 -0
  22. package/dist/tools/company-context-policy.d.ts +16 -0
  23. package/dist/tools/company-context-policy.js +59 -0
  24. package/dist/tools/context.d.ts +31 -0
  25. package/dist/tools/context.js +2 -0
  26. package/dist/tools/dedicated.d.ts +110 -0
  27. package/dist/tools/dedicated.js +227 -0
  28. package/dist/tools/dispatch.d.ts +4 -0
  29. package/dist/tools/dispatch.js +115 -0
  30. package/dist/tools/lists.d.ts +49 -0
  31. package/dist/tools/lists.js +265 -0
  32. package/dist/tools/meta.d.ts +7 -0
  33. package/dist/tools/meta.js +73 -0
  34. package/dist/tools/reads.d.ts +7 -0
  35. package/dist/tools/reads.js +277 -0
  36. package/dist/tools/register.d.ts +12 -0
  37. package/dist/tools/register.js +55 -0
  38. package/dist/tools/result-urls.d.ts +2 -0
  39. package/dist/tools/result-urls.js +47 -0
  40. package/dist/tools/results.d.ts +97 -0
  41. package/dist/tools/results.js +155 -0
  42. package/dist/tools/schemas.d.ts +119 -0
  43. package/dist/tools/schemas.js +142 -0
  44. package/dist/tools/search-policy.d.ts +124 -0
  45. package/dist/tools/search-policy.js +236 -0
  46. package/dist/tools/search-prose.d.ts +79 -0
  47. package/dist/tools/search-prose.js +327 -0
  48. package/dist/tools/search.d.ts +6 -0
  49. package/dist/tools/search.js +173 -0
  50. package/dist/tools/server.d.ts +77 -0
  51. package/dist/tools/server.js +319 -0
  52. package/dist/tools.d.ts +34 -0
  53. package/dist/tools.js +34 -0
  54. package/package.json +59 -0
package/dist/config.js ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * config.ts — the process's entire configuration surface, validated once at boot.
3
+ *
4
+ * Every knob is an environment variable. The server refuses to start on an invalid config rather
5
+ * than failing at the first tool call: a missing credential discovered by a model mid-conversation
6
+ * is a far worse failure than a container that never came up.
7
+ *
8
+ */
9
+ import * as z from 'zod';
10
+ import { getCapability } from 'node-hudu/capabilities';
11
+ import { SEARCH_RESOURCES } from 'node-hudu/mcp';
12
+ import { isWriteEffect } from './policy.js';
13
+ /**
14
+ * `stdio` is the local path (Claude Code, Claude Desktop, VS Code): one process, one credential,
15
+ * the process boundary IS the trust boundary. `http` is the remote path (an HTTP MCP client)
16
+ * and takes the caller's Hudu credential per request.
17
+ */
18
+ export const TRANSPORTS = ['stdio', 'http'];
19
+ /**
20
+ * The deployment's authority over `hudu_read / hudu_write / hudu_delete`'s write surface.
21
+ *
22
+ * `deny` is the default on purpose: an operator who forgets the variable gets a read-only server,
23
+ * never an open one. `all` is today's behaviour and has to be typed out deliberately.
24
+ */
25
+ export const WRITE_POLICIES = ['deny', 'allow_list', 'all'];
26
+ /**
27
+ * The deployment's authority over reads that can return a plaintext credential.
28
+ *
29
+ * `asset_passwords.get` returns `password` and `otp_secret` in plaintext as an ordinary read, so no
30
+ * write policy touches it — so it needs its own flag that closes both reachable paths in one place. `deny` is the default for the same reason
31
+ * {@link WRITE_POLICIES} defaults that way: an operator who forgets the variable gets a server that
32
+ * cannot hand a secret to a model, never one that can.
33
+ */
34
+ export const SECRET_READ_POLICIES = ['deny', 'allow'];
35
+ /** Parse `HUDU_WRITE_ALLOW` once, here, so every consumer reads the same list. */
36
+ function parseAllowList(raw) {
37
+ return (raw ?? '')
38
+ .split(',')
39
+ .map((key) => key.trim())
40
+ .filter((key) => key.length > 0);
41
+ }
42
+ /**
43
+ * Parse `HUDU_SEARCH_RESOURCES` once, here, so every consumer reads the same list.
44
+ *
45
+ * `undefined` means the variable is unset: the SDK's full searchable set. A set-but-empty value is
46
+ * a list, not a default — the caller asked to narrow the surface and named nothing, which is a
47
+ * config error rather than a server that silently searches nothing.
48
+ */
49
+ function parseSearchResources(raw) {
50
+ if (raw === undefined)
51
+ return undefined;
52
+ const parsed = raw
53
+ .split(',')
54
+ .map((resource) => resource.trim())
55
+ .filter((resource) => resource.length > 0);
56
+ // Normalise duplicates: a repeated name would otherwise inflate the effective list's length and
57
+ // make a membership check against the SDK set read as "full" while a resource is missing.
58
+ return [...new Set(parsed)];
59
+ }
60
+ /**
61
+ * Parse `HUDU_ALLOWED_BASE_HOSTS`: unset or blank stays `undefined` (no list); otherwise a
62
+ * lowercased, de-duplicated list. Validation of the entries happens in the schema refinement.
63
+ */
64
+ function parseBaseHosts(raw) {
65
+ if (raw === undefined || raw.trim() === '')
66
+ return undefined;
67
+ const parsed = raw
68
+ .split(',')
69
+ .map((host) => host.trim().toLowerCase())
70
+ .filter((host) => host.length > 0);
71
+ return [...new Set(parsed)];
72
+ }
73
+ /**
74
+ * A plausible hostname label (RFC 952/1123): 1-63 characters of lowercase alphanumerics and
75
+ * hyphens, no leading or trailing hyphen. The `*` wildcard is not a real label: it is legal only
76
+ * as the first label of a `*.` suffix entry, which the caller strips before delegating here.
77
+ */
78
+ const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
79
+ /**
80
+ * Whether an entry could match a real hostname, or only a typo.
81
+ *
82
+ * The boot-time regex below rejects what is plainly not a hostname (scheme, port, path, a bare
83
+ * `*`), but its middle `[a-z0-9.-]*` also admits entries that are structurally impossible in any
84
+ * parsed hostname: an empty label (consecutive or edge dots), a label over 63 characters, or a
85
+ * label with a leading or trailing hyphen. Such an entry boots cleanly and then sits in an active
86
+ * list matching nothing — a silent dead entry, so it must fail boot just like a scheme or port
87
+ * entry (see `baseHostAllowed` for what a live entry matches).
88
+ */
89
+ function isPlausibleHost(entry) {
90
+ const body = entry.startsWith('*.') ? entry.slice(2) : entry;
91
+ if (body === '')
92
+ return false;
93
+ return body.split('.').every((label) => HOST_LABEL.test(label));
94
+ }
95
+ const schema = z
96
+ .object({
97
+ /**
98
+ * The Hudu origin. Required under `stdio`; an optional default under `http`.
99
+ *
100
+ * On `stdio` the process IS the trust boundary, so the origin comes from the environment like
101
+ * any other local configuration. Under `http` it is the origin a request falls back to when it
102
+ * sends no `x-hudu-base-url`; a header, when present, still wins. A blank value
103
+ * (`HUDU_BASE_URL=`, as a copied `.env.example` leaves it) is normalised to unset first: an
104
+ * empty string is not an origin, so it must mean "no default" rather than fail boot.
105
+ */
106
+ HUDU_BASE_URL: z.preprocess((raw) => (typeof raw === 'string' && raw.trim() === '' ? undefined : raw), z.string().url('HUDU_BASE_URL must be an absolute URL, e.g. https://hudu.example.com').optional()),
107
+ /**
108
+ * The server-held Hudu credential — required on `stdio`, an optional default on `http`.
109
+ *
110
+ * On `stdio` the process IS the trust boundary, so the key comes from the environment like any
111
+ * other local credential. On `http` the key normally arrives per request in `x-hudu-api-key`;
112
+ * when set here it is the default for a request that brings none, and is only ever presented to
113
+ * the default `HUDU_BASE_URL` (see `resolveHuduClient`). A blank value (`HUDU_API_KEY=`, as a
114
+ * copied `.env.example` leaves it) is normalised to unset first: an empty string is not a
115
+ * credential, so it must select the same path an absent variable does rather than fail boot.
116
+ */
117
+ HUDU_API_KEY: z.preprocess((raw) => (typeof raw === 'string' && raw.trim() === '' ? undefined : raw), z.string().min(1, 'HUDU_API_KEY must not be empty').optional()),
118
+ /**
119
+ * Hosts a caller may name in `x-hudu-base-url` under `http` — the SSRF guard for that header.
120
+ *
121
+ * Comma-separated exact hostnames or `*.example.com` subdomain suffixes. When unset alongside
122
+ * a default `HUDU_BASE_URL`, only that default origin is accepted. When both are unset, any
123
+ * caller-named origin would be accepted (bring-your-own-origin mode, where the SDK still requires
124
+ * http/https and no path), so that mode must be opted into with {@link HUDU_ALLOW_ANY_BASE_HOST}.
125
+ * The default `HUDU_BASE_URL` is always usable and is not checked against an explicit list.
126
+ * Ignored under `stdio`, where no caller names an origin.
127
+ */
128
+ HUDU_ALLOWED_BASE_HOSTS: z.string().optional().transform(parseBaseHosts),
129
+ /**
130
+ * Explicit opt-out of the allow-list requirement under `http`. When `true` and no list is set,
131
+ * any origin a caller names in `x-hudu-base-url` is accepted (the SDK still requires http/https
132
+ * and no path), so the server will send requests, with the caller's key, to hosts of the
133
+ * caller's choosing. Only for a listener reachable by trusted callers alone. Defaults to `false`.
134
+ */
135
+ HUDU_ALLOW_ANY_BASE_HOST: z.enum(['true', 'false']).default('false').transform((value) => value === 'true'),
136
+ MCP_TRANSPORT: z.enum(TRANSPORTS).default('stdio'),
137
+ /**
138
+ * Pages the body index walks per resource per build (SDK default 250 = 25,000 records).
139
+ *
140
+ * Tenant-shaped, so it is a knob rather than a constant. The walk runs articles first and assets
141
+ * second, and both compete for one 64 MB text budget that evicts least-recently-read text: on a
142
+ * tenant with tens of thousands of assets the asset text arrives last, so the ARTICLE BODIES are
143
+ * the first thing evicted — and article bodies are the only reason this index exists (assets
144
+ * carry no body, and Hudu's own ?search= already covers their titles and fields). Sizing this so
145
+ * the article walk completes with budget left over is what keeps searches from going body-blind.
146
+ * A tenant with 3,000+ articles needs about 32 pages.
147
+ */
148
+ HUDU_SEARCH_MAX_INDEX_PAGES: z.coerce.number().int().min(1).max(1000).optional(),
149
+ /**
150
+ * The resources `hudu_search` may offer and query — the read-edge counterpart to
151
+ * `HUDU_WRITE_POLICY`.
152
+ *
153
+ * The SDK publishes eight searchable resources, but a deployment's Hudu credential can read
154
+ * fewer (a Hudu API key inherits its user's role). Unset means the SDK's full set; set it to the
155
+ * resources THIS credential can read so the tool never advertises a guaranteed-empty search and
156
+ * a model never spends a turn on a resource the key is not allowed to read. Every name is
157
+ * validated against the SDK's searchable set at boot, so a typo fails the container instead of a
158
+ * call weeks later. Applies to `hudu_search` only; the dedicated resolve tools are unaffected.
159
+ */
160
+ HUDU_SEARCH_RESOURCES: z.string().optional().transform(parseSearchResources),
161
+ /**
162
+ * Whether `hudu_read / hudu_write / hudu_delete` may reach a `write` or `destructive` operation at all. Read/`null`
163
+ * effects are never touched by this policy. Defaults to `deny` — see {@link WRITE_POLICIES}.
164
+ */
165
+ HUDU_READ_ONLY: z.enum(['true', 'false']).default('true').transform((value) => value === 'true'),
166
+ HUDU_WRITE_POLICY: z.enum(WRITE_POLICIES).default('deny'),
167
+ /**
168
+ * The operation keys `HUDU_WRITE_POLICY=allow_list` permits. Read only under `allow_list`;
169
+ * ignored under `deny`/`all`. Exposed as the parsed array so no consumer re-splits the string.
170
+ */
171
+ HUDU_WRITE_ALLOW: z.string().optional().transform(parseAllowList),
172
+ /**
173
+ * Whether any operation on a credential-bearing resource may run at all — the secret-read
174
+ * counterpart to `HUDU_WRITE_POLICY`, and independent of it.
175
+ *
176
+ * Scoped to the whole `asset_passwords` / `password_folders` surface rather than to the `expand`
177
+ * flag alone: `asset_passwords.get` returns the secret without `expand`, so gating the flag
178
+ * would leave the plainest path open. Defaults to `deny` — see {@link SECRET_READ_POLICIES}.
179
+ * `hudu_search` is unaffected; it redacts those resources already. The curated context reads
180
+ * always have asset custom-field values masked by the SDK, under every policy.
181
+ */
182
+ HUDU_SECRET_READS: z.enum(SECRET_READ_POLICIES).default('deny'),
183
+ PORT: z.coerce.number().int().positive().max(65535).default(8787),
184
+ LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
185
+ })
186
+ .superRefine((c, ctx) => {
187
+ // `stdio` has exactly one origin and one credential (the environment's), so it requires both.
188
+ // Under `http` both are optional defaults: a caller's `x-hudu-*` headers win, and the defaults
189
+ // fill in whatever a request leaves out (see `resolveHuduClient` for how they pair).
190
+ if (c.MCP_TRANSPORT === 'stdio' && c.HUDU_API_KEY === undefined) {
191
+ ctx.addIssue({
192
+ code: 'custom',
193
+ path: ['HUDU_API_KEY'],
194
+ message: 'HUDU_API_KEY is required when MCP_TRANSPORT is "stdio"',
195
+ });
196
+ }
197
+ if (c.MCP_TRANSPORT === 'stdio' && c.HUDU_BASE_URL === undefined) {
198
+ ctx.addIssue({
199
+ code: 'custom',
200
+ path: ['HUDU_BASE_URL'],
201
+ message: 'HUDU_BASE_URL is required when MCP_TRANSPORT is "stdio"',
202
+ });
203
+ }
204
+ // Fail closed: with no allow-list and no default origin, any caller picks the origin this server
205
+ // calls (bring-your-own-origin mode). Booting that way has to be typed out, never reached by
206
+ // forgetting a variable. A set `HUDU_BASE_URL` already restricts callers to that one origin.
207
+ if (c.MCP_TRANSPORT === 'http' &&
208
+ c.HUDU_ALLOWED_BASE_HOSTS === undefined &&
209
+ c.HUDU_BASE_URL === undefined &&
210
+ !c.HUDU_ALLOW_ANY_BASE_HOST) {
211
+ ctx.addIssue({
212
+ code: 'custom',
213
+ path: ['HUDU_ALLOWED_BASE_HOSTS'],
214
+ message: 'or HUDU_BASE_URL is required when MCP_TRANSPORT is "http": without either, any caller can make this server send requests to a host of their choosing. Set one (e.g. HUDU_BASE_URL=https://hudu.example.com), or set HUDU_ALLOW_ANY_BASE_HOST=true to accept any origin deliberately',
215
+ });
216
+ }
217
+ // A host entry is a bare hostname or `*.` suffix. A scheme, port or path would never match the
218
+ // parsed hostname, so it would silently refuse every caller; fail boot instead.
219
+ for (const host of c.HUDU_ALLOWED_BASE_HOSTS ?? []) {
220
+ if (!/^(\*\.)?[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(host)) {
221
+ ctx.addIssue({
222
+ code: 'custom',
223
+ path: ['HUDU_ALLOWED_BASE_HOSTS'],
224
+ message: `"${host}" is not a hostname; use e.g. hudu.example.com or *.example.com (no scheme, port or path)`,
225
+ });
226
+ }
227
+ else if (!isPlausibleHost(host)) {
228
+ // Passes the shape check but matches no real hostname: an empty label (consecutive dots),
229
+ // an over-long label, or a label with a leading or trailing hyphen. A dead entry in an
230
+ // active list is a config error, not a harmless one — name it and fail boot.
231
+ ctx.addIssue({
232
+ code: 'custom',
233
+ path: ['HUDU_ALLOWED_BASE_HOSTS'],
234
+ message: `"${host}" can match no hostname (labels are 1-63 characters, no leading or trailing hyphen, no consecutive dots); use e.g. hudu.example.com or *.example.com`,
235
+ });
236
+ }
237
+ }
238
+ // The search-resource list is independent of the write policy, so it is validated before the
239
+ // early return below. A set list must be non-empty and every name must exist in the SDK's
240
+ // searchable set — a typo fails boot with the variable and the name, not one empty search later.
241
+ if (c.HUDU_SEARCH_RESOURCES !== undefined) {
242
+ if (c.HUDU_SEARCH_RESOURCES.length === 0) {
243
+ ctx.addIssue({
244
+ code: 'custom',
245
+ path: ['HUDU_SEARCH_RESOURCES'],
246
+ message: 'must name at least one searchable resource when set (an empty list would make hudu_search search nothing)',
247
+ });
248
+ }
249
+ else {
250
+ const searchable = new Set(SEARCH_RESOURCES.searchable.map((r) => r.resource));
251
+ for (const resource of c.HUDU_SEARCH_RESOURCES) {
252
+ if (!searchable.has(resource)) {
253
+ ctx.addIssue({
254
+ code: 'custom',
255
+ path: ['HUDU_SEARCH_RESOURCES'],
256
+ message: `"${resource}" is not a searchable resource; the SDK searchable set is ${[...searchable].join(', ')}`,
257
+ });
258
+ }
259
+ }
260
+ }
261
+ }
262
+ // `HUDU_WRITE_ALLOW` is only meaningful under `allow_list`, where a bad list must fail the
263
+ // container rather than a call six months later. Every problem is reported at once, on the one
264
+ // error path `loadConfig` already owns.
265
+ if (c.HUDU_WRITE_POLICY !== 'allow_list')
266
+ return;
267
+ if (c.HUDU_WRITE_ALLOW.length === 0) {
268
+ ctx.addIssue({
269
+ code: 'custom',
270
+ path: ['HUDU_WRITE_ALLOW'],
271
+ message: 'must list at least one operation key when HUDU_WRITE_POLICY=allow_list (an empty list is a config error, not a silently read-only server)',
272
+ });
273
+ return;
274
+ }
275
+ for (const key of c.HUDU_WRITE_ALLOW) {
276
+ const record = getCapability(key);
277
+ if (record === undefined) {
278
+ ctx.addIssue({
279
+ code: 'custom',
280
+ path: ['HUDU_WRITE_ALLOW'],
281
+ message: `"${key}" is not a registry operation key; check the spelling against hudu_list_operations`,
282
+ });
283
+ }
284
+ else if (!isWriteEffect(record)) {
285
+ // An allow-list entry must name a write. Assert the positive rather than negating `read`:
286
+ // `getCapability` is a prototype-backed lookup, so an inherited name (`constructor`,
287
+ // `toString`) resolves to a function whose `effect` is `undefined`, not to `undefined`.
288
+ // Testing `effect === 'read' || effect === null` let those keys fall through as writes.
289
+ const detail = record.effect === 'read' || record.effect === null
290
+ ? `"${key}" is a ${record.effect ?? 'non-write'} operation`
291
+ : `"${key}" is not a write operation`;
292
+ // It grants nothing either way, so silently accepting it would let the config's apparent
293
+ // scope differ from its effect. A non-write key in a write allow-list is a misunderstanding
294
+ // worth failing on.
295
+ ctx.addIssue({
296
+ code: 'custom',
297
+ path: ['HUDU_WRITE_ALLOW'],
298
+ message: `${detail}; HUDU_WRITE_ALLOW is a write allow-list and it would grant nothing`,
299
+ });
300
+ }
301
+ }
302
+ });
303
+ /**
304
+ * Parse and validate configuration from an environment-shaped record.
305
+ *
306
+ * Takes the environment as an argument rather than reading `process.env` directly so the failure
307
+ * paths are testable without mutating global state.
308
+ *
309
+ * @throws {ConfigError} when any variable is missing or malformed. The message lists every problem
310
+ * at once — a boot loop that surfaces one missing variable per restart wastes real minutes.
311
+ */
312
+ export function loadConfig(env = process.env) {
313
+ const parsed = schema.safeParse(env);
314
+ if (parsed.success)
315
+ return parsed.data;
316
+ const problems = parsed.error.issues.map((i) => ` ${i.path.join('.') || '(config)'}: ${i.message}`);
317
+ throw new ConfigError(`Invalid configuration:\n${problems.join('\n')}`);
318
+ }
319
+ /** Thrown only by {@link loadConfig}. Never carries a credential value — only variable names. */
320
+ export class ConfigError extends Error {
321
+ name = 'ConfigError';
322
+ }
323
+ //# sourceMappingURL=config.js.map
package/dist/http.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * http.ts — the remote transport: streamable HTTP.
3
+ *
4
+ * This is the path remote MCP clients use. Many clients speak streamable HTTP only
5
+ * (one POST endpoint), so this transport is not optional for that consumer. The per-caller Hudu key
6
+ * in `X-Hudu-Api-Key` — together with the origin in `X-Hudu-Base-Url` — is what the tools use to
7
+ * reach Hudu, unless the optional `HUDU_API_KEY` / `HUDU_BASE_URL` defaults fill them in — see
8
+ * `src/auth.ts` and `resolveHuduClient`.
9
+ * The transport boundary is the network: the listener must stay on a private, unpublished network.
10
+ *
11
+ * A request with no Hudu key is allowed through so the SDK can serve `initialize`/`tools/list` for
12
+ * a catalog sync; it resolves to a credential-less client that fails closed on any tool call, so
13
+ * no caller borrows an identity.
14
+ *
15
+ */
16
+ import { type IncomingMessage, type ServerResponse, type Server } from 'node:http';
17
+ import type { Config } from './config.js';
18
+ import type { Logger } from './logger.js';
19
+ /** A fetch-shaped MCP handler, as returned by the SDK's `createMcpHandler`. */
20
+ export interface FetchHandler {
21
+ fetch(request: Request): Promise<Response>;
22
+ }
23
+ /** Liveness path. Answered before authentication so a container healthcheck needs no credential. */
24
+ export declare const HEALTH_PATH = "/healthz";
25
+ /**
26
+ * Build the fetch-level request handler: health, then MCP.
27
+ *
28
+ * Split out from the listener so the request path is testable without binding a port.
29
+ */
30
+ export declare function createFetchHandler(mcp: FetchHandler): (request: Request) => Promise<Response>;
31
+ /** Node has no fetch-shaped HTTP server; this is the smallest bridge to one. */
32
+ export declare function toWebRequest(req: IncomingMessage, port: number): Promise<Request>;
33
+ /**
34
+ * Stream a web Response into the Node response — incrementally, never buffered.
35
+ *
36
+ * Buffering would deliver nothing to the client until the upstream body closed, hanging any
37
+ * long-lived or incremental body.
38
+ */
39
+ export declare function send(res: ServerResponse, response: Response): Promise<void>;
40
+ /**
41
+ * Bind the listener. `mcp` is supplied by the caller so this module never builds tools itself.
42
+ *
43
+ * `exit` is a parameter only so a test can watch a listen failure without killing the test runner.
44
+ */
45
+ export declare function startHttpServer(config: Config, mcp: FetchHandler, log: Logger, exit?: (code: number) => void): Server;
package/dist/http.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * http.ts — the remote transport: streamable HTTP.
3
+ *
4
+ * This is the path remote MCP clients use. Many clients speak streamable HTTP only
5
+ * (one POST endpoint), so this transport is not optional for that consumer. The per-caller Hudu key
6
+ * in `X-Hudu-Api-Key` — together with the origin in `X-Hudu-Base-Url` — is what the tools use to
7
+ * reach Hudu, unless the optional `HUDU_API_KEY` / `HUDU_BASE_URL` defaults fill them in — see
8
+ * `src/auth.ts` and `resolveHuduClient`.
9
+ * The transport boundary is the network: the listener must stay on a private, unpublished network.
10
+ *
11
+ * A request with no Hudu key is allowed through so the SDK can serve `initialize`/`tools/list` for
12
+ * a catalog sync; it resolves to a credential-less client that fails closed on any tool call, so
13
+ * no caller borrows an identity.
14
+ *
15
+ */
16
+ import { createServer } from 'node:http';
17
+ import { Readable } from 'node:stream';
18
+ import { pipeline } from 'node:stream/promises';
19
+ import { HUDU_BASE_URL_HEADER, HUDU_KEY_HEADER } from './auth.js';
20
+ /** The headers that carry per-request scope, and so must never be read from a duplicated pair. */
21
+ const CREDENTIAL_HEADERS = new Set([HUDU_KEY_HEADER, HUDU_BASE_URL_HEADER]);
22
+ /** Liveness path. Answered before authentication so a container healthcheck needs no credential. */
23
+ export const HEALTH_PATH = '/healthz';
24
+ /**
25
+ * Build the fetch-level request handler: health, then MCP.
26
+ *
27
+ * Split out from the listener so the request path is testable without binding a port.
28
+ */
29
+ export function createFetchHandler(mcp) {
30
+ return async function handle(request) {
31
+ const url = new URL(request.url);
32
+ // Liveness only. It must not touch Hudu — see the Dockerfile HEALTHCHECK note.
33
+ if (url.pathname === HEALTH_PATH) {
34
+ return Response.json({ status: 'ok' });
35
+ }
36
+ // No check for the Hudu key: a request with no key is let through so the SDK can answer
37
+ // `initialize`/`tools/list`, which never reach Hudu and which clients need to sync the tool
38
+ // catalog. The per-request factory resolves such a request to a client that holds no credential
39
+ // and throws before any Hudu request is built, so every tool call fails closed and no caller
40
+ // borrows an identity.
41
+ return mcp.fetch(request);
42
+ };
43
+ }
44
+ /** Node has no fetch-shaped HTTP server; this is the smallest bridge to one. */
45
+ export async function toWebRequest(req, port) {
46
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? `localhost:${port}`}`);
47
+ const headers = new Headers();
48
+ // Iterate `headersDistinct`, not `headers`. Node has already joined a duplicated custom header
49
+ // with ", " in `req.headers`, so by the time a `Headers` bag was built the two values would look
50
+ // like one well-formed credential and pass a non-blank check. The distinct view keeps them apart
51
+ // so a credential presented twice can be treated as absent — see `readSingleHeader`.
52
+ for (const [name, values] of Object.entries(req.headersDistinct)) {
53
+ if (values === undefined || values.length === 0)
54
+ continue;
55
+ if (values.length > 1) {
56
+ // A credential presented more than once is malformed. Drop it rather than trust one value,
57
+ // which is the rule `readSingleHeader` applies to an array. Non-credential duplicates keep
58
+ // Node's combining behaviour.
59
+ if (CREDENTIAL_HEADERS.has(name))
60
+ continue;
61
+ for (const one of values)
62
+ headers.append(name, one);
63
+ continue;
64
+ }
65
+ // `values.length` is exactly 1 here (empty was skipped, greater-than-one handled above).
66
+ headers.set(name, values[0]);
67
+ }
68
+ if (req.method === 'GET' || req.method === 'HEAD')
69
+ return new Request(url, { method: req.method, headers });
70
+ const chunks = [];
71
+ for await (const chunk of req)
72
+ chunks.push(Buffer.from(chunk));
73
+ return new Request(url, { method: req.method, headers, body: Buffer.concat(chunks).toString('utf8') });
74
+ }
75
+ /**
76
+ * Stream a web Response into the Node response — incrementally, never buffered.
77
+ *
78
+ * Buffering would deliver nothing to the client until the upstream body closed, hanging any
79
+ * long-lived or incremental body.
80
+ */
81
+ export async function send(res, response) {
82
+ res.statusCode = response.status;
83
+ response.headers.forEach((value, name) => res.setHeader(name, value));
84
+ if (response.body === null || res.req.method === 'HEAD') {
85
+ res.end();
86
+ return;
87
+ }
88
+ try {
89
+ await pipeline(Readable.fromWeb(response.body), res);
90
+ }
91
+ catch {
92
+ // Status and headers are already on the wire; pipeline has destroyed both ends.
93
+ }
94
+ }
95
+ /**
96
+ * Bind the listener. `mcp` is supplied by the caller so this module never builds tools itself.
97
+ *
98
+ * `exit` is a parameter only so a test can watch a listen failure without killing the test runner.
99
+ */
100
+ export function startHttpServer(config, mcp, log, exit = process.exit) {
101
+ const handle = createFetchHandler(mcp);
102
+ const server = createServer((req, res) => {
103
+ void toWebRequest(req, config.PORT)
104
+ .then(handle)
105
+ .then((response) => send(res, response))
106
+ .catch((err) => {
107
+ // Never echo the error to the caller: it can carry internal detail.
108
+ log.error('request failed', { err: err instanceof Error ? err.message : String(err) });
109
+ if (!res.headersSent) {
110
+ res.statusCode = 500;
111
+ res.end('internal error');
112
+ }
113
+ else {
114
+ res.destroy();
115
+ }
116
+ });
117
+ });
118
+ // Without a listener here, a bind failure (EADDRINUSE, EACCES on a privileged port) is an
119
+ // unhandled 'error' event and Node rethrows it as a raw stack trace. It is the same class of boot
120
+ // failure as an invalid config: name the problem, exit non-zero, so an orchestrator sees a failed
121
+ // container rather than a healthy-but-useless one.
122
+ server.on('error', (err) => {
123
+ log.error('listen failed', { port: config.PORT, err: err.message });
124
+ exit(1);
125
+ });
126
+ server.listen(config.PORT, () => log.info('listening', { port: config.PORT, transport: 'http' }));
127
+ return server;
128
+ }
129
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * lifecycle.ts — process-level crash, signal and exit logging for the http transport.
3
+ *
4
+ * Node's default SIGTERM handling is silent: a killed container leaves zero log lines, and the
5
+ * only way to learn the process died was to watch the port free (a server that died mid-run left no crash line at all). The http transport installs process-level handlers here so a crash,
6
+ * a signal or a clean exit always leaves a record in the server log:
7
+ *
8
+ * - `uncaughtException` / `unhandledRejection` → a structured error line, then exit 1
9
+ * - `SIGTERM` / `SIGINT` → a "shutting down" line (signal + exit code), a short grace period for
10
+ * in-flight work, a final "server closed" line, then exit 0
11
+ *
12
+ * The stdio transport keeps its own handler in main.ts, unchanged: its process life is tied to
13
+ * the client, and no orchestrator signals it.
14
+ */
15
+ import type { Logger } from './logger.js';
16
+ /** The slice of `node:http`'s Server that shutdown needs: stop accepting, then force-close sockets. */
17
+ export interface CloseableServer {
18
+ close(callback?: (err?: Error) => void): void;
19
+ closeAllConnections?(): void;
20
+ }
21
+ /** How long shutdown waits for in-flight work before force-closing keep-alive sockets. */
22
+ export declare const SHUTDOWN_GRACE_MS = 2000;
23
+ /**
24
+ * Install the process-level lifecycle handlers for the http transport.
25
+ *
26
+ * `exit` is a parameter so a test can watch the code without killing the test runner — the same
27
+ * pattern as `startHttpServer`'s listen-failure path.
28
+ */
29
+ export declare function installHttpLifecycle(server: CloseableServer, log: Logger, exit?: (code: number) => void): void;
@@ -0,0 +1,52 @@
1
+ /** How long shutdown waits for in-flight work before force-closing keep-alive sockets. */
2
+ export const SHUTDOWN_GRACE_MS = 2000;
3
+ /**
4
+ * Install the process-level lifecycle handlers for the http transport.
5
+ *
6
+ * `exit` is a parameter so a test can watch the code without killing the test runner — the same
7
+ * pattern as `startHttpServer`'s listen-failure path.
8
+ */
9
+ export function installHttpLifecycle(server, log, exit = process.exit) {
10
+ // Node's default unhandledRejection handling already terminates — silently. The handler turns
11
+ // that into a log line and an explicit exit code; uncaughtException gets the same treatment.
12
+ process.on('uncaughtException', (err) => {
13
+ log.error('uncaught exception', { err: describe(err) });
14
+ exit(1);
15
+ });
16
+ process.on('unhandledRejection', (reason) => {
17
+ log.error('unhandled rejection', { err: describe(reason) });
18
+ exit(1);
19
+ });
20
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
21
+ process.on('SIGINT', () => shutdown('SIGINT'));
22
+ // A second signal mid-shutdown is a nudge, not a new shutdown: without the guard the log would
23
+ // carry two "shutting down" lines and two forced exits.
24
+ let shuttingDown = false;
25
+ function shutdown(signal) {
26
+ if (shuttingDown)
27
+ return;
28
+ shuttingDown = true;
29
+ log.info('shutting down', { signal, exitCode: 0 });
30
+ let finished = false;
31
+ const finish = () => {
32
+ if (finished)
33
+ return;
34
+ finished = true;
35
+ log.info('server closed', { signal });
36
+ exit(0);
37
+ };
38
+ server.close(() => finish());
39
+ // MCP clients hold keep-alive connections, which can hold `close` open indefinitely. After
40
+ // the grace window force-close them and finish no matter what `close` says. `unref` keeps
41
+ // the timer from pinning the event loop on the fast path.
42
+ setTimeout(() => {
43
+ server.closeAllConnections?.();
44
+ finish();
45
+ }, SHUTDOWN_GRACE_MS).unref();
46
+ }
47
+ }
48
+ /** An Error as its stack (or message); anything else stringified. Same shape as main.ts' stdio handler. */
49
+ function describe(value) {
50
+ return value instanceof Error ? (value.stack ?? value.message) : String(value);
51
+ }
52
+ //# sourceMappingURL=lifecycle.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * logger.ts — structured JSON logs to stdout, one object per line.
3
+ *
4
+ * Deliberately tiny: a container's log collector wants newline-delimited JSON on stdout and nothing
5
+ * else. No dependency, no transports, no file rotation.
6
+ *
7
+ * On the stdio transport, stdout IS the MCP protocol channel — writing logs there would corrupt
8
+ * every message. Logs go to stderr in that case; see {@link createLogger}.
9
+ */
10
+ export declare const LEVELS: readonly ["debug", "info", "warn", "error"];
11
+ export type Level = (typeof LEVELS)[number];
12
+ export interface Logger {
13
+ debug(msg: string, fields?: Record<string, unknown>): void;
14
+ info(msg: string, fields?: Record<string, unknown>): void;
15
+ warn(msg: string, fields?: Record<string, unknown>): void;
16
+ error(msg: string, fields?: Record<string, unknown>): void;
17
+ }
18
+ /**
19
+ * @param minLevel messages below this are dropped.
20
+ * @param write sink for one complete line. Defaults to stderr, which is always safe: on the stdio
21
+ * transport stdout carries the MCP protocol itself, so a log line written there would be parsed
22
+ * as a protocol message and break the session.
23
+ */
24
+ export declare function createLogger(minLevel?: Level, write?: (line: string) => void): Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * logger.ts — structured JSON logs to stdout, one object per line.
3
+ *
4
+ * Deliberately tiny: a container's log collector wants newline-delimited JSON on stdout and nothing
5
+ * else. No dependency, no transports, no file rotation.
6
+ *
7
+ * On the stdio transport, stdout IS the MCP protocol channel — writing logs there would corrupt
8
+ * every message. Logs go to stderr in that case; see {@link createLogger}.
9
+ */
10
+ export const LEVELS = ['debug', 'info', 'warn', 'error'];
11
+ /**
12
+ * @param minLevel messages below this are dropped.
13
+ * @param write sink for one complete line. Defaults to stderr, which is always safe: on the stdio
14
+ * transport stdout carries the MCP protocol itself, so a log line written there would be parsed
15
+ * as a protocol message and break the session.
16
+ */
17
+ export function createLogger(minLevel = 'info', write = (l) => process.stderr.write(l + '\n')) {
18
+ const threshold = LEVELS.indexOf(minLevel);
19
+ const at = (level) => (msg, fields) => {
20
+ if (LEVELS.indexOf(level) < threshold)
21
+ return;
22
+ write(JSON.stringify({ ts: new Date().toISOString(), level, msg, ...fields }));
23
+ };
24
+ return { debug: at('debug'), info: at('info'), warn: at('warn'), error: at('error') };
25
+ }
26
+ //# sourceMappingURL=logger.js.map
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};