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.
- package/.env.example +81 -0
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/auth.d.ts +51 -0
- package/dist/auth.js +84 -0
- package/dist/base-hosts.d.ts +20 -0
- package/dist/base-hosts.js +39 -0
- package/dist/config.d.ts +86 -0
- package/dist/config.js +323 -0
- package/dist/http.d.ts +45 -0
- package/dist/http.js +129 -0
- package/dist/lifecycle.d.ts +29 -0
- package/dist/lifecycle.js +52 -0
- package/dist/logger.d.ts +24 -0
- package/dist/logger.js +26 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +56 -0
- package/dist/policy.d.ts +79 -0
- package/dist/policy.js +175 -0
- package/dist/stdio.d.ts +21 -0
- package/dist/stdio.js +29 -0
- package/dist/tools/company-context-policy.d.ts +16 -0
- package/dist/tools/company-context-policy.js +59 -0
- package/dist/tools/context.d.ts +31 -0
- package/dist/tools/context.js +2 -0
- package/dist/tools/dedicated.d.ts +110 -0
- package/dist/tools/dedicated.js +227 -0
- package/dist/tools/dispatch.d.ts +4 -0
- package/dist/tools/dispatch.js +115 -0
- package/dist/tools/lists.d.ts +49 -0
- package/dist/tools/lists.js +265 -0
- package/dist/tools/meta.d.ts +7 -0
- package/dist/tools/meta.js +73 -0
- package/dist/tools/reads.d.ts +7 -0
- package/dist/tools/reads.js +277 -0
- package/dist/tools/register.d.ts +12 -0
- package/dist/tools/register.js +55 -0
- package/dist/tools/result-urls.d.ts +2 -0
- package/dist/tools/result-urls.js +47 -0
- package/dist/tools/results.d.ts +97 -0
- package/dist/tools/results.js +155 -0
- package/dist/tools/schemas.d.ts +119 -0
- package/dist/tools/schemas.js +142 -0
- package/dist/tools/search-policy.d.ts +124 -0
- package/dist/tools/search-policy.js +236 -0
- package/dist/tools/search-prose.d.ts +79 -0
- package/dist/tools/search-prose.js +327 -0
- package/dist/tools/search.d.ts +6 -0
- package/dist/tools/search.js +173 -0
- package/dist/tools/server.d.ts +77 -0
- package/dist/tools/server.js +319 -0
- package/dist/tools.d.ts +34 -0
- package/dist/tools.js +34 -0
- 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
|
package/dist/logger.d.ts
ADDED
|
@@ -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