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/.env.example
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# The Hudu origin the server talks to -- REQUIRED for stdio, an optional default for http.
|
|
2
|
+
#
|
|
3
|
+
# stdio: the process IS the trust boundary, so the origin comes from the environment.
|
|
4
|
+
# http: callers normally name their own origin per request in the X-Hudu-Base-Url header. When set,
|
|
5
|
+
# this is the origin used by a request that sends no such header. The default key below is
|
|
6
|
+
# only ever presented to this origin.
|
|
7
|
+
# HUDU_BASE_URL=https://hudu.example.com
|
|
8
|
+
|
|
9
|
+
# stdio (local: Claude Code, Claude Desktop, VS Code) or http (remote MCP clients).
|
|
10
|
+
MCP_TRANSPORT=stdio
|
|
11
|
+
|
|
12
|
+
# The server-held Hudu credential -- REQUIRED for stdio, an optional default for http.
|
|
13
|
+
#
|
|
14
|
+
# stdio: the process IS the trust boundary, so the key comes from the environment.
|
|
15
|
+
# http: the key normally arrives per request in the X-Hudu-Api-Key header (a client can scope it
|
|
16
|
+
# per user or per agent, so each caller reaches Hudu as itself). When set here, every request
|
|
17
|
+
# WITHOUT its own key runs as this key, so set it only when that is intended. It is only ever
|
|
18
|
+
# sent to HUDU_BASE_URL, never to a caller-named origin. Left commented so a copied example
|
|
19
|
+
# does not set an empty value.
|
|
20
|
+
# HUDU_API_KEY=
|
|
21
|
+
|
|
22
|
+
# http only, optional. Hosts a caller may name in X-Hudu-Base-Url: comma-separated exact hostnames or
|
|
23
|
+
# *.example.com subdomain suffixes (no scheme, port or path). Unset alongside a set HUDU_BASE_URL,
|
|
24
|
+
# only that default origin is accepted. Unset with no HUDU_BASE_URL, any caller-named origin would be
|
|
25
|
+
# accepted, so whoever can reach the listener could make the server dial hosts of their choosing:
|
|
26
|
+
# the server refuses to boot in that case unless HUDU_ALLOW_ANY_BASE_HOST=true. An explicit list
|
|
27
|
+
# never checks HUDU_BASE_URL against it.
|
|
28
|
+
# HUDU_ALLOWED_BASE_HOSTS=hudu.example.com,*.corp.example.com
|
|
29
|
+
|
|
30
|
+
# http only. Boot without an allow-list and accept any origin a caller names. Use only when every
|
|
31
|
+
# caller that can reach the listener is trusted.
|
|
32
|
+
# HUDU_ALLOW_ANY_BASE_HOST=false
|
|
33
|
+
|
|
34
|
+
# Omit mutation tools entirely by default. Set false before enabling a write policy.
|
|
35
|
+
HUDU_READ_ONLY=true
|
|
36
|
+
|
|
37
|
+
# Authority over the mutation dispatchers. deny (the default) refuses every write and destructive
|
|
38
|
+
# operation; allow_list permits only the keys in HUDU_WRITE_ALLOW; all is today's behaviour and has
|
|
39
|
+
# to be typed out deliberately. An unset value means deny, so forgetting this gets a read-only
|
|
40
|
+
# server, never an open one. Reads are never affected.
|
|
41
|
+
HUDU_WRITE_POLICY=deny
|
|
42
|
+
|
|
43
|
+
# Authority over reads that return a plaintext credential -- independent of HUDU_WRITE_POLICY, which
|
|
44
|
+
# never covers them: asset_passwords.get returns password and otp_secret as an ordinary read. deny
|
|
45
|
+
# (the default) refuses every asset_passwords.* and password_folders.* operation, through the dispatchers
|
|
46
|
+
# and through hudu_find_asset_passwords_by_slug / hudu_get_password_folder alike. Scoped to the whole
|
|
47
|
+
# resource, not to the expand flag, because the secret comes back without expand. hudu_search is
|
|
48
|
+
# unaffected; it redacts those resources already.
|
|
49
|
+
HUDU_SECRET_READS=deny
|
|
50
|
+
|
|
51
|
+
# Only read under HUDU_WRITE_POLICY=allow_list. Comma-separated registry operation keys, e.g.
|
|
52
|
+
# articles.update,assets.create. It must be non-empty and every key must exist in the registry (and
|
|
53
|
+
# be a write) or the container fails at boot with the variable named. Ignored under deny/all.
|
|
54
|
+
# HUDU_WRITE_ALLOW=articles.update,assets.create
|
|
55
|
+
|
|
56
|
+
PORT=8787
|
|
57
|
+
LOG_LEVEL=info
|
|
58
|
+
|
|
59
|
+
# Pages the search body index walks per resource (SDK default 250 = 25,000 records). Lower it when
|
|
60
|
+
# the tenant's assets are numerous enough to evict article bodies from the 64 MB text budget: the
|
|
61
|
+
# article walk must fit with room to spare or every search answers body-blind. 40 covers ~4,000
|
|
62
|
+
# articles and caps assets at ~4,000.
|
|
63
|
+
HUDU_SEARCH_MAX_INDEX_PAGES=40
|
|
64
|
+
|
|
65
|
+
# The resources hudu_search and hudu_resolve_any may offer and query. Unset means the SDK's full
|
|
66
|
+
# searchable set (articles, asset_passwords, assets, companies, groups, password_folders, users,
|
|
67
|
+
# websites). Set it to the subset THIS deployment's Hudu API key can read, so a tool neither
|
|
68
|
+
# advertises a guaranteed-empty read nor leaks an authorization failure for a resource the key
|
|
69
|
+
# cannot reach. A Hudu API key inherits its user's role, so a "Reader" key may read fewer than all
|
|
70
|
+
# eight. Comma-separated, each name validated against the SDK's searchable set at boot. Applies to
|
|
71
|
+
# the cross-resource reads (hudu_search, hudu_resolve_any) and the same operations through
|
|
72
|
+
# hudu_read; the dedicated per-resource resolve tools are unaffected.
|
|
73
|
+
# HUDU_SEARCH_RESOURCES=articles,assets,companies,users,groups,websites
|
|
74
|
+
|
|
75
|
+
# Image tag docker-compose.yml builds and runs as. scripts/deploy.sh sets it to the git short SHA
|
|
76
|
+
# of the deployed commit, so the running container is always tied to a commit and the previous
|
|
77
|
+
# tag stays resolvable for rollback. Do not deploy by hand with it unset, and never tag :latest.
|
|
78
|
+
# Leave it out of .env — the deploy script exports it.
|
|
79
|
+
# HUDU_IMAGE_TAG=
|
|
80
|
+
|
|
81
|
+
# HUDU_SECRET_READS=allow permits resource access; SDK results remain redacted.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max Soukhomlinov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# iit-mcp-hudu
|
|
2
|
+
|
|
3
|
+
MCP server for Hudu IT documentation, built on the [`node-hudu`](https://www.npmjs.com/package/node-hudu) SDK.
|
|
4
|
+
|
|
5
|
+
## Tool surface
|
|
6
|
+
|
|
7
|
+
19 tools by default: `hudu_list_operations`, `hudu_describe_operation`, `hudu_read`, twelve
|
|
8
|
+
core reads, and four bounded list reads. Setting `HUDU_READ_ONLY=false` adds `hudu_write` and
|
|
9
|
+
`hudu_delete` (21 tools). Each dispatcher uses a separate SDK client in its matching mode,
|
|
10
|
+
with names, annotations and operation enums from the SDK's curated metadata. SDK enforcement
|
|
11
|
+
rejects cross-effect calls before requests. The old `hudu_invoke`, `hudu_catalog` and
|
|
12
|
+
`hudu_describe` names are removed; consumers must resync their tool catalog.
|
|
13
|
+
|
|
14
|
+
The four list tools each wrap exactly ONE fixed registry operation (`companies.list` /
|
|
15
|
+
`articles.list` / `assets.listAcrossCompanies` / `asset_layouts.list`), take exactly one page and
|
|
16
|
+
accept no operation selector or nested invocation, so an ungated Reader can page companies,
|
|
17
|
+
articles, assets or asset layouts without using a registry dispatcher and without exposing the write
|
|
18
|
+
surface. The first three take `page` / `page_size` (capped at 100); `hudu_list_asset_layouts` takes
|
|
19
|
+
`page` only, because `/asset_layouts` ignores `page_size` — a caller bound would truncate a vendor
|
|
20
|
+
page and skip rows as `page` advanced, so the tool returns the vendor page whole and page N is
|
|
21
|
+
always the vendor's Nth page. For the searchable resources (`companies` / `articles` /
|
|
22
|
+
`assets`) a resource excluded by `HUDU_SEARCH_RESOURCES` is refused by the list tool too, before any
|
|
23
|
+
request; `asset_layouts` is not searchable (the SDK declares no vendor text filter for it), so
|
|
24
|
+
`hudu_list_asset_layouts` follows the same reachability as the existing ungated
|
|
25
|
+
`hudu_get_asset_layout` tool.
|
|
26
|
+
|
|
27
|
+
## Transports
|
|
28
|
+
|
|
29
|
+
| Transport | For | Caller auth | Hudu credential |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `stdio` | Claude Code, Claude Desktop, VS Code | none — the process boundary is the trust boundary | `HUDU_API_KEY` + `HUDU_BASE_URL` env |
|
|
32
|
+
| `http` | Remote MCP clients | none — private, unpublished network | per request: `X-Hudu-Api-Key` + `X-Hudu-Base-Url` headers, or the optional `HUDU_API_KEY` + `HUDU_BASE_URL` defaults |
|
|
33
|
+
|
|
34
|
+
## Local use (stdio)
|
|
35
|
+
|
|
36
|
+
Needs Node 24+, a Hudu API key and your Hudu origin. `stdio` is the default transport, so no
|
|
37
|
+
`MCP_TRANSPORT` is needed. Mutation tools stay off unless you set `HUDU_READ_ONLY=false`.
|
|
38
|
+
|
|
39
|
+
**From a clone:**
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
git clone https://github.com/msoukhomlinov/iit-mcp-hudu.git
|
|
43
|
+
cd iit-mcp-hudu
|
|
44
|
+
npm install
|
|
45
|
+
npm run build
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then register `dist/main.js` with your client, e.g. Claude Code:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
claude mcp add hudu \
|
|
52
|
+
--env HUDU_API_KEY=your-key --env HUDU_BASE_URL=https://your.hudu.host \
|
|
53
|
+
-- node /absolute/path/to/iit-mcp-hudu/dist/main.js
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
or in `.mcp.json` / Claude Desktop config:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"hudu": {
|
|
62
|
+
"command": "node",
|
|
63
|
+
"args": ["/absolute/path/to/iit-mcp-hudu/dist/main.js"],
|
|
64
|
+
"env": { "HUDU_API_KEY": "your-key", "HUDU_BASE_URL": "https://your.hudu.host" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**From npm:**
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
claude mcp add hudu \
|
|
74
|
+
--env HUDU_API_KEY=your-key --env HUDU_BASE_URL=https://your.hudu.host \
|
|
75
|
+
-- npx -y iit-mcp-hudu
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
or `"command": "npx", "args": ["-y", "iit-mcp-hudu"]` in the JSON config above.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
Copy `.env.example`. Every variable is validated at boot; the process exits non-zero on an invalid
|
|
83
|
+
config rather than failing at the first tool call.
|
|
84
|
+
|
|
85
|
+
Under `http` the caller normally supplies both per request: its own Hudu API key in `X-Hudu-Api-Key`
|
|
86
|
+
and the origin that key belongs to in `X-Hudu-Base-Url` (an absolute `http`/`https` origin, no
|
|
87
|
+
path — validated by the SDK when the scoped client is built). A client can scope the key
|
|
88
|
+
per user or per agent to match how the connection is configured, so a caller can read exactly
|
|
89
|
+
what its own Hudu role permits and no more. There is no shared token: the listener is
|
|
90
|
+
unauthenticated and must stay on a private, unpublished network.
|
|
91
|
+
|
|
92
|
+
Both may instead be set as **defaults**. `HUDU_BASE_URL` and `HUDU_API_KEY` are required under
|
|
93
|
+
`stdio` and optional under `http`, where a request's headers win and the defaults fill in whatever
|
|
94
|
+
it omits. Setting `HUDU_API_KEY` under `http` means every request without its own key runs as that
|
|
95
|
+
key, so set it only when that is intended: with no shared token, the private network is the only
|
|
96
|
+
gate on who can use it. It is only ever presented to the default
|
|
97
|
+
`HUDU_BASE_URL`: a request that names some other origin and brings no key of its own gets no
|
|
98
|
+
credential and fails closed, so a caller cannot aim the server's key at a host it controls. (A
|
|
99
|
+
blank value, as a copied `.env.example` leaves it, is treated as unset.) Each (origin, credential)
|
|
100
|
+
pair gets its own scoped client (its own search index) cached by the pair's hash and capped, so
|
|
101
|
+
one caller's indexed article bodies can never answer another's search.
|
|
102
|
+
|
|
103
|
+
`HUDU_ALLOWED_BASE_HOSTS` (`http` only; required unless `HUDU_ALLOW_ANY_BASE_HOST=true`) restricts which origins a caller may name in
|
|
104
|
+
`X-Hudu-Base-Url`: a comma-separated list of exact hostnames or `*.example.com` subdomain suffixes
|
|
105
|
+
(no scheme, port or path; write an internationalised name in punycode). When it is unset and
|
|
106
|
+
`HUDU_BASE_URL` is set, only that default origin is accepted. When both are unset, any caller-named
|
|
107
|
+
origin would be accepted, so anyone who can reach the listener could make the server send requests
|
|
108
|
+
(carrying their key) to hosts of their choosing; the server refuses to boot in that case unless
|
|
109
|
+
`HUDU_ALLOW_ANY_BASE_HOST=true` opts in, for a listener only trusted callers can reach. The check is
|
|
110
|
+
on the hostname string: it does not resolve DNS or block private addresses, so do not list a name you
|
|
111
|
+
do not control. A request naming a host outside the effective policy fails closed on every tool call
|
|
112
|
+
without dialing it. Only the hostname is checked for an explicit list: an allowed host is accepted on
|
|
113
|
+
any port and over `http`. The default `HUDU_BASE_URL` is never checked against an explicit list.
|
|
114
|
+
|
|
115
|
+
With no usable key (none in the header and no `HUDU_API_KEY` default) a request is still let
|
|
116
|
+
through: `initialize` and `tools/list` never reach Hudu, and a client's catalog sync needs them. Such
|
|
117
|
+
a request resolves to a credential-less client that throws before any Hudu request is built, so
|
|
118
|
+
every tool call fails closed rather than borrowing an identity. A request that has a key but no
|
|
119
|
+
origin (no header and no `HUDU_BASE_URL` default) is let through the same way and fails closed on
|
|
120
|
+
every tool call, with the error naming the missing header. A
|
|
121
|
+
credential header sent twice (which Node would otherwise join with `", "`) is treated as absent
|
|
122
|
+
for the same reason.
|
|
123
|
+
|
|
124
|
+
`HUDU_READ_ONLY=true` is the default and omits both mutation tools entirely, regardless of
|
|
125
|
+
write policy. To enable mutations, explicitly set it to `false` and configure
|
|
126
|
+
`HUDU_WRITE_POLICY`: `deny` rejects all mutations, `allow_list` permits only the canonical
|
|
127
|
+
keys in `HUDU_WRITE_ALLOW`, and `all` permits the SDK's mutation surface. An empty or invalid
|
|
128
|
+
allow-list fails startup. The SDK clients receive the same allow-list plus reads needed by
|
|
129
|
+
helper dependencies; client mode and effect dispatch remain independent restrictions.
|
|
130
|
+
Writes default to dry-run previews; execution requires `dry_run:false`, and destructive
|
|
131
|
+
operations additionally require exact `confirm`. A confirmation string is not human consent.
|
|
132
|
+
Discovery reports deployment-policy refusals as unreachable.
|
|
133
|
+
|
|
134
|
+
`HUDU_WRITE_POLICY` stays a **deployment-wide** decision, not a per-caller one. Per-caller
|
|
135
|
+
credentials make each caller's Hudu role an external authority too, but that authority is not ours
|
|
136
|
+
to trust and cannot be allowed to widen this server's surface: the server-side policy is the floor,
|
|
137
|
+
`deny` by default, and it holds even when a caller presents a key whose Hudu role could write. The
|
|
138
|
+
same applies to `HUDU_SECRET_READS` and `HUDU_SEARCH_RESOURCES` — all three narrow what *this
|
|
139
|
+
server* will ever offer, independent of what any one caller's key might otherwise permit.
|
|
140
|
+
|
|
141
|
+
`HUDU_SECRET_READS` governs the other thing no write policy covers: reads that return a plaintext
|
|
142
|
+
credential. `asset_passwords.get` returns `password` and `otp_secret` as an ordinary read, and the
|
|
143
|
+
curated tools `hudu_find_asset_passwords_by_slug` and `hudu_get_password_folder` reach the same
|
|
144
|
+
records. It is `deny` by default and refuses every operation on the `asset_passwords` and
|
|
145
|
+
`password_folders` resources — through `hudu_read` and through those two tools alike, before any
|
|
146
|
+
request is issued — so a deployment cannot hand a secret to a model by omission. It is scoped to the
|
|
147
|
+
whole resource rather than to the `expand` flag, because `asset_passwords.get` returns the secret
|
|
148
|
+
without `expand`. It is independent of `HUDU_WRITE_POLICY`: `all` does not imply open secrets, and
|
|
149
|
+
`allow` does not open writes. `hudu_search` is unaffected — it already redacts hits on those two
|
|
150
|
+
resources. Refusals are `POLICY_DENIED` and appear as unreachable rows in `hudu_list_operations`.
|
|
151
|
+
|
|
152
|
+
All SDK clients keep default redaction enabled, including when `HUDU_SECRET_READS=allow`.
|
|
153
|
+
That flag permits credential-resource operations; it never enables plaintext results. The
|
|
154
|
+
SDK masks credential fields and confidential or unclassified custom fields as `[REDACTED]`.
|
|
155
|
+
Untyped fields may therefore be masked even when an external layout calls them public.
|
|
156
|
+
Under `deny`, both company-context paths additionally project password rows to the summary
|
|
157
|
+
field allow-list, dropping free-text descriptions and unknown future fields. No extra layout
|
|
158
|
+
lookups are needed. The common result
|
|
159
|
+
boundary also uses the SDK redactor with `otp_seed`, `recovery_code`, and `recovery_codes`
|
|
160
|
+
as additional secret keys, preserving protections absent from the SDK's default key list.
|
|
161
|
+
|
|
162
|
+
Result text is a JSON envelope labelled `untrusted_data`; its fixed instruction marks both
|
|
163
|
+
text and `structuredContent` as data, never instructions or authorization to invoke tools.
|
|
164
|
+
|
|
165
|
+
`HUDU_SEARCH_RESOURCES` is the read-edge counterpart: it narrows which resources the cross-resource
|
|
166
|
+
reads — `hudu_search` and `hudu_resolve_any`, plus the same operations reached through `hudu_read`
|
|
167
|
+
— may offer and query, because a Hudu API key inherits its user's role and may read fewer than the
|
|
168
|
+
SDK's eight searchable resources. Unset means the full set; set it to what the credential can read so
|
|
169
|
+
the schema never advertises a guaranteed-empty read. A per-resource read failure, and an auth failure
|
|
170
|
+
thrown by the resolve fan-out, are reported as unavailable — never with Hudu's own authorization
|
|
171
|
+
message, which is a fact about the calling credential, not the caller's query.
|
|
172
|
+
|
|
173
|
+
Under `http` each caller's search scope starts cold, so `hudu_search` defaults to `tier:"auto"`
|
|
174
|
+
rather than `"index"`: it answers from Hudu search immediately and builds the body index in the
|
|
175
|
+
background, so a caller's later search is body-aware without the first one awaiting a corpus-wide
|
|
176
|
+
walk (some clients enforce a 60s call timeout). Under `stdio` the one long-lived index stays warm and the
|
|
177
|
+
default is `"index"`. Pass `tier:"index"` explicitly for a complete first answer.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
npm install
|
|
183
|
+
npm test
|
|
184
|
+
npm run dev
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
CI (`.github/workflows/ci.yml`) runs `typecheck`, `lint` and `test` on every push and pull request.
|
|
188
|
+
|
|
189
|
+
`node-hudu` is pinned to `0.9.2`. The catalog hash test detects metadata drift; upgrades require
|
|
190
|
+
review of the client authority, operation enums and redaction regressions. Docker uses `npm ci`.
|
|
191
|
+
|
|
192
|
+
## Deployment
|
|
193
|
+
|
|
194
|
+
`Dockerfile` builds a non-root runtime image. The healthcheck is liveness-only and deliberately does
|
|
195
|
+
not call Hudu — otherwise an upstream outage would cycle the container, turning a partial failure
|
|
196
|
+
into a total one.
|
|
197
|
+
|
|
198
|
+
The deployment definition is `docker-compose.yml`: its own Compose project, joining an existing
|
|
199
|
+
`app_net` external network (create it with `docker network create app_net`, or rename it in the
|
|
200
|
+
file). The listener is not published to the host, so put a TLS-terminating reverse proxy in front of
|
|
201
|
+
it, or share the network with the client that calls it. Copy `.env.example` to `.env` and fill it
|
|
202
|
+
in, then deploy from a clean git checkout with:
|
|
203
|
+
|
|
204
|
+
scripts/deploy.sh
|
|
205
|
+
|
|
206
|
+
The script tags the build with the git short SHA of HEAD (`iit-mcp-hudu-iit-mcp-hudu:<sha>`) —
|
|
207
|
+
never `:latest` — so the running container is always tied to a commit.
|
|
208
|
+
|
|
209
|
+
### Rolling back
|
|
210
|
+
|
|
211
|
+
A rebuild cannot touch a previous tag, so it stays resolvable in the local image store. Roll back
|
|
212
|
+
from the same checkout — and only from it:
|
|
213
|
+
|
|
214
|
+
docker image inspect iit-mcp-hudu-iit-mcp-hudu:<previous-sha> >/dev/null # must exist locally
|
|
215
|
+
HUDU_IMAGE_TAG=<previous-sha> docker compose up -d --no-build --pull never
|
|
216
|
+
|
|
217
|
+
`--no-build` and `--pull never` are not optional: the service declares both `image` and `build`,
|
|
218
|
+
so without them Compose would fall back to pulling — or building the current source under the old
|
|
219
|
+
tag — if the image is missing, a rollback that deploys the new code instead. The compose file
|
|
220
|
+
also pins `pull_policy: never` as the backstop for any invocation, and the `docker image inspect`
|
|
221
|
+
line makes a pruned or absent image fail loudly before anything is started.
|
|
222
|
+
|
|
223
|
+
The image that was running before the first tagged deploy was `:latest` (unpinned); until a newer
|
|
224
|
+
pinned tag exists it remains the rollback target via the same two commands with `latest`.
|
|
225
|
+
|
|
226
|
+
## Release checklist
|
|
227
|
+
|
|
228
|
+
- Server identity (`name` / `version` reported in `initialize`) is hardcoded in `src/tools/server.ts`,
|
|
229
|
+
because `rootDir: "src"` forbids importing `package.json` from across the repo root. Bump it there
|
|
230
|
+
as well as in `package.json`.
|
|
231
|
+
- `BUILT_AGAINST_PLAN_HASH` in `src/tools.ts` pins the SDK catalogue this server was built against.
|
|
232
|
+
If its test fails after an SDK upgrade, re-read the catalogue and re-check the 19 registered tools,
|
|
233
|
+
then move the constant. Never move it to make CI green — that is the one thing it exists to stop.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.ts — caller authentication for the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Two per-request headers answer different questions:
|
|
5
|
+
*
|
|
6
|
+
* - `X-Hudu-Api-Key` is the caller's own Hudu key. Under the `http` transport the server normally
|
|
7
|
+
* holds no Hudu credential of its own (an operator may set an optional `HUDU_API_KEY` default for
|
|
8
|
+
* requests that bring none): the client supplies the key per request,
|
|
9
|
+
* scoped per user or per agent to match how the connection is configured. That key is
|
|
10
|
+
* what actually reaches Hudu, so a caller can read exactly what its own Hudu role permits and no
|
|
11
|
+
* more.
|
|
12
|
+
* - `X-Hudu-Base-Url` is the origin that key belongs to. It is not a secret — the caller supplied
|
|
13
|
+
* it and only ever sees its own value — but it decides which Hudu tenant the key is presented
|
|
14
|
+
* to, so it is scoped exactly like the key: per request, single-valued, absent when duplicated,
|
|
15
|
+
* and never in the server's config surface or tool schemas.
|
|
16
|
+
*
|
|
17
|
+
* The legacy `X-MCP-Token` shared-secret gate is gone (see the 2026-09-29 amendment to the design
|
|
18
|
+
* spec): the transport boundary is the private, unpublished network the listener must stay on. A
|
|
19
|
+
* client that still sends `x-mcp-token` is answered exactly as if the header were not there.
|
|
20
|
+
*
|
|
21
|
+
* Neither header ever appears in a tool's input schema. See design spec §5.4.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The header carrying the caller's Hudu API key.
|
|
25
|
+
*
|
|
26
|
+
* Fixed rather than configurable: it is the wire contract clients are built to send, and making it a
|
|
27
|
+
* knob would let a deployment and its only consumer disagree silently. The value is per request, so
|
|
28
|
+
* it never enters the server's config surface, logs, or tool schemas.
|
|
29
|
+
*/
|
|
30
|
+
export declare const HUDU_KEY_HEADER = "x-hudu-api-key";
|
|
31
|
+
/**
|
|
32
|
+
* The header carrying the caller's Hudu base URL — the origin its key belongs to.
|
|
33
|
+
*
|
|
34
|
+
* Sibling of {@link HUDU_KEY_HEADER}: fixed rather than configurable, per request, and never in
|
|
35
|
+
* the server's config surface or tool schemas. It is not a secret — the caller supplied it and
|
|
36
|
+
* only ever sees its own value — so it is not bound by the key's no-logging rule; the
|
|
37
|
+
* single-valued rule still applies, though: a base URL presented twice is absent, exactly as with
|
|
38
|
+
* the key.
|
|
39
|
+
*/
|
|
40
|
+
export declare const HUDU_BASE_URL_HEADER = "x-hudu-base-url";
|
|
41
|
+
/** Read the caller's per-request Hudu API key from request headers. */
|
|
42
|
+
export declare function readHuduKey(headers: Record<string, string | string[] | undefined>): string | undefined;
|
|
43
|
+
/** Read the caller's per-request Hudu base URL from request headers. */
|
|
44
|
+
export declare function readHuduBase(headers: Record<string, string | string[] | undefined>): string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Collapse a web `Headers` bag into the lowercased record shape the readers expect.
|
|
47
|
+
*
|
|
48
|
+
* A header given twice becomes an array, which the readers treat as absent — see
|
|
49
|
+
* {@link readSingleHeader}. That is the whole reason this cannot just be `Object.fromEntries`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function headerRecord(headers: Headers): Record<string, string | string[] | undefined>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.ts — caller authentication for the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Two per-request headers answer different questions:
|
|
5
|
+
*
|
|
6
|
+
* - `X-Hudu-Api-Key` is the caller's own Hudu key. Under the `http` transport the server normally
|
|
7
|
+
* holds no Hudu credential of its own (an operator may set an optional `HUDU_API_KEY` default for
|
|
8
|
+
* requests that bring none): the client supplies the key per request,
|
|
9
|
+
* scoped per user or per agent to match how the connection is configured. That key is
|
|
10
|
+
* what actually reaches Hudu, so a caller can read exactly what its own Hudu role permits and no
|
|
11
|
+
* more.
|
|
12
|
+
* - `X-Hudu-Base-Url` is the origin that key belongs to. It is not a secret — the caller supplied
|
|
13
|
+
* it and only ever sees its own value — but it decides which Hudu tenant the key is presented
|
|
14
|
+
* to, so it is scoped exactly like the key: per request, single-valued, absent when duplicated,
|
|
15
|
+
* and never in the server's config surface or tool schemas.
|
|
16
|
+
*
|
|
17
|
+
* The legacy `X-MCP-Token` shared-secret gate is gone (see the 2026-09-29 amendment to the design
|
|
18
|
+
* spec): the transport boundary is the private, unpublished network the listener must stay on. A
|
|
19
|
+
* client that still sends `x-mcp-token` is answered exactly as if the header were not there.
|
|
20
|
+
*
|
|
21
|
+
* Neither header ever appears in a tool's input schema. See design spec §5.4.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The header carrying the caller's Hudu API key.
|
|
25
|
+
*
|
|
26
|
+
* Fixed rather than configurable: it is the wire contract clients are built to send, and making it a
|
|
27
|
+
* knob would let a deployment and its only consumer disagree silently. The value is per request, so
|
|
28
|
+
* it never enters the server's config surface, logs, or tool schemas.
|
|
29
|
+
*/
|
|
30
|
+
export const HUDU_KEY_HEADER = 'x-hudu-api-key';
|
|
31
|
+
/**
|
|
32
|
+
* The header carrying the caller's Hudu base URL — the origin its key belongs to.
|
|
33
|
+
*
|
|
34
|
+
* Sibling of {@link HUDU_KEY_HEADER}: fixed rather than configurable, per request, and never in
|
|
35
|
+
* the server's config surface or tool schemas. It is not a secret — the caller supplied it and
|
|
36
|
+
* only ever sees its own value — so it is not bound by the key's no-logging rule; the
|
|
37
|
+
* single-valued rule still applies, though: a base URL presented twice is absent, exactly as with
|
|
38
|
+
* the key.
|
|
39
|
+
*/
|
|
40
|
+
export const HUDU_BASE_URL_HEADER = 'x-hudu-base-url';
|
|
41
|
+
/**
|
|
42
|
+
* Read a single-valued header, tolerating Node's array-valued header shape.
|
|
43
|
+
*
|
|
44
|
+
* A duplicated header is treated as absent rather than trusting the first value: a request that
|
|
45
|
+
* presents two different credentials is malformed, and picking one is how request-smuggling bugs
|
|
46
|
+
* start.
|
|
47
|
+
*/
|
|
48
|
+
function readSingleHeader(headers, name) {
|
|
49
|
+
const raw = headers[name];
|
|
50
|
+
if (typeof raw === 'string')
|
|
51
|
+
return raw;
|
|
52
|
+
if (Array.isArray(raw) && raw.length === 1)
|
|
53
|
+
return raw[0];
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Read the caller's per-request Hudu API key from request headers. */
|
|
57
|
+
export function readHuduKey(headers) {
|
|
58
|
+
return readSingleHeader(headers, HUDU_KEY_HEADER);
|
|
59
|
+
}
|
|
60
|
+
/** Read the caller's per-request Hudu base URL from request headers. */
|
|
61
|
+
export function readHuduBase(headers) {
|
|
62
|
+
return readSingleHeader(headers, HUDU_BASE_URL_HEADER);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Collapse a web `Headers` bag into the lowercased record shape the readers expect.
|
|
66
|
+
*
|
|
67
|
+
* A header given twice becomes an array, which the readers treat as absent — see
|
|
68
|
+
* {@link readSingleHeader}. That is the whole reason this cannot just be `Object.fromEntries`.
|
|
69
|
+
*/
|
|
70
|
+
export function headerRecord(headers) {
|
|
71
|
+
const out = {};
|
|
72
|
+
headers.forEach((value, name) => {
|
|
73
|
+
const key = name.toLowerCase();
|
|
74
|
+
const existing = out[key];
|
|
75
|
+
if (existing === undefined)
|
|
76
|
+
out[key] = value;
|
|
77
|
+
else if (Array.isArray(existing))
|
|
78
|
+
existing.push(value);
|
|
79
|
+
else
|
|
80
|
+
out[key] = [existing, value];
|
|
81
|
+
});
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base-hosts.ts — the optional allow-list for client-supplied Hudu origins.
|
|
3
|
+
*
|
|
4
|
+
* Under `http` a caller names the origin its key is presented to (`x-hudu-base-url`), which makes
|
|
5
|
+
* this server an outbound HTTP client for whoever can reach the listener. The SDK only checks the
|
|
6
|
+
* scheme and that there is no path, so when `HUDU_ALLOWED_BASE_HOSTS` is set this is what keeps a
|
|
7
|
+
* caller from pointing the server at an internal host or a hostile one.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Whether `baseUrl`'s host is permitted by `allowed`.
|
|
11
|
+
*
|
|
12
|
+
* An entry is an exact hostname (`hudu.example.com`) or a `*.` suffix (`*.example.com`) that
|
|
13
|
+
* matches subdomains only, never the bare domain. Unset (`undefined`) permits everything at this
|
|
14
|
+
* level; the http server layers the effective policy on top (default-only when a default origin is
|
|
15
|
+
* set, bring-your-own-origin when it is not). A URL that does not parse is refused rather than
|
|
16
|
+
* passed through, so the SDK's own error is never the gate.
|
|
17
|
+
*/
|
|
18
|
+
export declare function baseHostAllowed(baseUrl: string, allowed: readonly string[] | undefined): boolean;
|
|
19
|
+
/** The origin of `url`, or `undefined` when it does not parse. Used to compare against the default. */
|
|
20
|
+
export declare function originOf(url: string): string | undefined;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base-hosts.ts — the optional allow-list for client-supplied Hudu origins.
|
|
3
|
+
*
|
|
4
|
+
* Under `http` a caller names the origin its key is presented to (`x-hudu-base-url`), which makes
|
|
5
|
+
* this server an outbound HTTP client for whoever can reach the listener. The SDK only checks the
|
|
6
|
+
* scheme and that there is no path, so when `HUDU_ALLOWED_BASE_HOSTS` is set this is what keeps a
|
|
7
|
+
* caller from pointing the server at an internal host or a hostile one.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Whether `baseUrl`'s host is permitted by `allowed`.
|
|
11
|
+
*
|
|
12
|
+
* An entry is an exact hostname (`hudu.example.com`) or a `*.` suffix (`*.example.com`) that
|
|
13
|
+
* matches subdomains only, never the bare domain. Unset (`undefined`) permits everything at this
|
|
14
|
+
* level; the http server layers the effective policy on top (default-only when a default origin is
|
|
15
|
+
* set, bring-your-own-origin when it is not). A URL that does not parse is refused rather than
|
|
16
|
+
* passed through, so the SDK's own error is never the gate.
|
|
17
|
+
*/
|
|
18
|
+
export function baseHostAllowed(baseUrl, allowed) {
|
|
19
|
+
if (allowed === undefined)
|
|
20
|
+
return true;
|
|
21
|
+
let host;
|
|
22
|
+
try {
|
|
23
|
+
host = new URL(baseUrl).hostname.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return allowed.some((entry) => entry.startsWith('*.') ? host.endsWith(entry.slice(1)) : host === entry);
|
|
29
|
+
}
|
|
30
|
+
/** The origin of `url`, or `undefined` when it does not parse. Used to compare against the default. */
|
|
31
|
+
export function originOf(url) {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(url).origin;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=base-hosts.js.map
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* `stdio` is the local path (Claude Code, Claude Desktop, VS Code): one process, one credential,
|
|
12
|
+
* the process boundary IS the trust boundary. `http` is the remote path (an HTTP MCP client)
|
|
13
|
+
* and takes the caller's Hudu credential per request.
|
|
14
|
+
*/
|
|
15
|
+
export declare const TRANSPORTS: readonly ["stdio", "http"];
|
|
16
|
+
export type Transport = (typeof TRANSPORTS)[number];
|
|
17
|
+
/**
|
|
18
|
+
* The deployment's authority over `hudu_read / hudu_write / hudu_delete`'s write surface.
|
|
19
|
+
*
|
|
20
|
+
* `deny` is the default on purpose: an operator who forgets the variable gets a read-only server,
|
|
21
|
+
* never an open one. `all` is today's behaviour and has to be typed out deliberately.
|
|
22
|
+
*/
|
|
23
|
+
export declare const WRITE_POLICIES: readonly ["deny", "allow_list", "all"];
|
|
24
|
+
export type WritePolicy = (typeof WRITE_POLICIES)[number];
|
|
25
|
+
/**
|
|
26
|
+
* The deployment's authority over reads that can return a plaintext credential.
|
|
27
|
+
*
|
|
28
|
+
* `asset_passwords.get` returns `password` and `otp_secret` in plaintext as an ordinary read, so no
|
|
29
|
+
* 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
|
|
30
|
+
* {@link WRITE_POLICIES} defaults that way: an operator who forgets the variable gets a server that
|
|
31
|
+
* cannot hand a secret to a model, never one that can.
|
|
32
|
+
*/
|
|
33
|
+
export declare const SECRET_READ_POLICIES: readonly ["deny", "allow"];
|
|
34
|
+
export type SecretReadPolicy = (typeof SECRET_READ_POLICIES)[number];
|
|
35
|
+
declare const schema: z.ZodObject<{
|
|
36
|
+
HUDU_BASE_URL: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
37
|
+
HUDU_API_KEY: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
38
|
+
HUDU_ALLOWED_BASE_HOSTS: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string[] | undefined, string | undefined>>;
|
|
39
|
+
HUDU_ALLOW_ANY_BASE_HOST: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
|
|
40
|
+
true: "true";
|
|
41
|
+
false: "false";
|
|
42
|
+
}>>, z.ZodTransform<boolean, "true" | "false">>;
|
|
43
|
+
MCP_TRANSPORT: z.ZodDefault<z.ZodEnum<{
|
|
44
|
+
stdio: "stdio";
|
|
45
|
+
http: "http";
|
|
46
|
+
}>>;
|
|
47
|
+
HUDU_SEARCH_MAX_INDEX_PAGES: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
48
|
+
HUDU_SEARCH_RESOURCES: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string[] | undefined, string | undefined>>;
|
|
49
|
+
HUDU_READ_ONLY: z.ZodPipe<z.ZodDefault<z.ZodEnum<{
|
|
50
|
+
true: "true";
|
|
51
|
+
false: "false";
|
|
52
|
+
}>>, z.ZodTransform<boolean, "true" | "false">>;
|
|
53
|
+
HUDU_WRITE_POLICY: z.ZodDefault<z.ZodEnum<{
|
|
54
|
+
deny: "deny";
|
|
55
|
+
allow_list: "allow_list";
|
|
56
|
+
all: "all";
|
|
57
|
+
}>>;
|
|
58
|
+
HUDU_WRITE_ALLOW: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string[], string | undefined>>;
|
|
59
|
+
HUDU_SECRET_READS: z.ZodDefault<z.ZodEnum<{
|
|
60
|
+
deny: "deny";
|
|
61
|
+
allow: "allow";
|
|
62
|
+
}>>;
|
|
63
|
+
PORT: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
64
|
+
LOG_LEVEL: z.ZodDefault<z.ZodEnum<{
|
|
65
|
+
debug: "debug";
|
|
66
|
+
info: "info";
|
|
67
|
+
warn: "warn";
|
|
68
|
+
error: "error";
|
|
69
|
+
}>>;
|
|
70
|
+
}, z.core.$strip>;
|
|
71
|
+
export type Config = z.infer<typeof schema>;
|
|
72
|
+
/**
|
|
73
|
+
* Parse and validate configuration from an environment-shaped record.
|
|
74
|
+
*
|
|
75
|
+
* Takes the environment as an argument rather than reading `process.env` directly so the failure
|
|
76
|
+
* paths are testable without mutating global state.
|
|
77
|
+
*
|
|
78
|
+
* @throws {ConfigError} when any variable is missing or malformed. The message lists every problem
|
|
79
|
+
* at once — a boot loop that surfaces one missing variable per restart wastes real minutes.
|
|
80
|
+
*/
|
|
81
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
|
|
82
|
+
/** Thrown only by {@link loadConfig}. Never carries a credential value — only variable names. */
|
|
83
|
+
export declare class ConfigError extends Error {
|
|
84
|
+
readonly name = "ConfigError";
|
|
85
|
+
}
|
|
86
|
+
export {};
|