@ni-c/mcp-hub 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -0
- package/README.md +26 -11
- package/dist/admin.js +4 -4
- package/dist/auth/address.js +1 -1
- package/dist/auth/oidc/interactions.js +40 -25
- package/dist/auth/oidc/provider.js +12 -0
- package/dist/auth/password.js +50 -0
- package/dist/auth/pinned-fetch.js +30 -0
- package/dist/auth/registration.js +5 -5
- package/dist/auth/session.js +32 -5
- package/dist/auth/signed-token.js +6 -2
- package/dist/auth/store.js +37 -29
- package/dist/child-text.js +39 -0
- package/dist/config.js +1 -5
- package/dist/docker-proxy/index.js +9 -2
- package/dist/docker-proxy/secrets-watcher.js +1 -1
- package/dist/docker-proxy/secrets.js +5 -1
- package/dist/docker-proxy/server.js +14 -0
- package/dist/elicitation.js +0 -0
- package/dist/forward.js +0 -0
- package/dist/hub.js +14 -7
- package/dist/index.js +19 -2
- package/dist/stdio.js +3 -1
- package/dist/subscriptions.js +11 -0
- package/dist/supervisor.js +23 -15
- package/dist/transports/docker.js +11 -9
- package/dist/transports/stream.js +10 -0
- package/dist/upstream/auth.js +15 -8
- package/dist/upstream/provider.js +1 -1
- package/dist/upstream/routes.js +27 -22
- package/package.json +6 -3
- package/dist/admin.js.map +0 -1
- package/dist/auth/address.js.map +0 -1
- package/dist/auth/api-tokens.js.map +0 -1
- package/dist/auth/cimd.js.map +0 -1
- package/dist/auth/consent-page.js.map +0 -1
- package/dist/auth/headers.js.map +0 -1
- package/dist/auth/login-page.js.map +0 -1
- package/dist/auth/oidc/adapter.js.map +0 -1
- package/dist/auth/oidc/interactions.js.map +0 -1
- package/dist/auth/oidc/mount.js.map +0 -1
- package/dist/auth/oidc/provider.js.map +0 -1
- package/dist/auth/oidc/quirks.js.map +0 -1
- package/dist/auth/oidc/verifier.js.map +0 -1
- package/dist/auth/page.js.map +0 -1
- package/dist/auth/pinned-fetch.js.map +0 -1
- package/dist/auth/protected-resource.js.map +0 -1
- package/dist/auth/rate-limit.js.map +0 -1
- package/dist/auth/redirect-uri.js.map +0 -1
- package/dist/auth/registration.js.map +0 -1
- package/dist/auth/resource.js.map +0 -1
- package/dist/auth/session.js.map +0 -1
- package/dist/auth/signed-token.js.map +0 -1
- package/dist/auth/store.js.map +0 -1
- package/dist/auth/text.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/docker-proxy/index.js.map +0 -1
- package/dist/docker-proxy/policy.js.map +0 -1
- package/dist/docker-proxy/secrets-watcher.js.map +0 -1
- package/dist/docker-proxy/secrets.js.map +0 -1
- package/dist/docker-proxy/server.js.map +0 -1
- package/dist/elicitation.js.map +0 -1
- package/dist/forward.js.map +0 -1
- package/dist/health.js.map +0 -1
- package/dist/hub.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/limits.js.map +0 -1
- package/dist/logfile.js.map +0 -1
- package/dist/main-module.js.map +0 -1
- package/dist/mcp-limits.js.map +0 -1
- package/dist/mount-check.js.map +0 -1
- package/dist/proxy.js.map +0 -1
- package/dist/sandbox/container-spec.js.map +0 -1
- package/dist/sandbox/docker-client.js.map +0 -1
- package/dist/sandbox/policy-protocol.js.map +0 -1
- package/dist/stdio.js.map +0 -1
- package/dist/subscriptions.js.map +0 -1
- package/dist/supervisor.js.map +0 -1
- package/dist/timings.js.map +0 -1
- package/dist/tool-cache.js.map +0 -1
- package/dist/tool-filter.js.map +0 -1
- package/dist/transports/docker.js.map +0 -1
- package/dist/transports/socket.js.map +0 -1
- package/dist/transports/stream.js.map +0 -1
- package/dist/upstream/auth.js.map +0 -1
- package/dist/upstream/login.js.map +0 -1
- package/dist/upstream/provider.js.map +0 -1
- package/dist/upstream/routes.js.map +0 -1
- package/dist/version.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,179 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
<!-- #region changelog -->
|
|
9
9
|
|
|
10
|
+
## [0.11.2] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **An unset password was an open login.** With neither `PASSWORD` nor
|
|
15
|
+
`PASSWORD_HASH` configured, `checkPassword` compared the form field with an
|
|
16
|
+
empty buffer — which matches an empty field — so anyone who could reach the
|
|
17
|
+
port approved a client and minted tokens for every server. The hub now starts
|
|
18
|
+
with its login **disabled** in that case: a warning on the first log line,
|
|
19
|
+
`503` with the reason on the sign-in page, and every login refused, so
|
|
20
|
+
nothing can be approved. A `PASSWORD_HASH` that is not a bcrypt hash
|
|
21
|
+
disables the login the same way instead of falling back to `PASSWORD`; the
|
|
22
|
+
warning names the variable and its length, never the value. Refusing to
|
|
23
|
+
*start* was considered and rejected: a health check or a directory crawler
|
|
24
|
+
that runs the image without a password needs the process, not the login,
|
|
25
|
+
and the security outcome is the same.
|
|
26
|
+
- **The private-address guard for an upstream's authorization server was off
|
|
27
|
+
for every DNS-named upstream.** `privateAllowed()` asked `isPrivateAddress`
|
|
28
|
+
about the upstream's *hostname*; that function answers "refuse" for anything
|
|
29
|
+
that is not an address literal, and the caller read "refuse" as "the upstream
|
|
30
|
+
is private, so its authorization server may be too". A public upstream whose
|
|
31
|
+
metadata pointed the token or registration endpoint at `127.0.0.1` or
|
|
32
|
+
`169.254.169.254` was followed there. The hostname is now resolved and the
|
|
33
|
+
upstream counts as private only when every answer is; a resolution failure
|
|
34
|
+
or a mixed answer keeps the guard on; and the endpoints must be https unless
|
|
35
|
+
the upstream is private.
|
|
36
|
+
- **The native-fetch path of the upstream OAuth client read bodies without a
|
|
37
|
+
ceiling.** Literal-IP and private upstreams bypass the pinned transport and
|
|
38
|
+
its 256 KiB cap; `boundedResponse()` applies the same cap there, including to
|
|
39
|
+
error bodies and decoded responses.
|
|
40
|
+
- **A forged `state` on `/upstream/callback` hung the request.** A signature of
|
|
41
|
+
the right length in characters but not in bytes made `timingSafeEqual` throw
|
|
42
|
+
`RangeError`, and the route's `void (async …)` swallowed the rejection, so
|
|
43
|
+
the browser waited forever. `signatureMatches` checks the shape before
|
|
44
|
+
comparing, `readSessionCookie` no longer throws on broken percent-encoding,
|
|
45
|
+
and both routes are async handlers whose rejection Express answers.
|
|
46
|
+
- **PKCE is required of every client, confidential ones included.**
|
|
47
|
+
oidc-provider's default asks for a `code_challenge` only from public
|
|
48
|
+
clients; the discovery document and the standards page have said "S256
|
|
49
|
+
required" since the first release. A secret proves who redeems a code, the
|
|
50
|
+
challenge proves it was the one who asked for it.
|
|
51
|
+
- **The session cookie carries the `__Host-` prefix behind HTTPS**, as
|
|
52
|
+
`docs/guide/security.md` has promised since it was written. The browser then
|
|
53
|
+
refuses the cookie from any other origin, path or domain, so a session
|
|
54
|
+
somebody obtained cannot be fixed into another browser from a sibling host.
|
|
55
|
+
Behind plain http, where the prefix is not settable, the bare name stays.
|
|
56
|
+
- **Prototype names as identifiers no longer fault the authorization server.**
|
|
57
|
+
Every map in `state.json` is keyed by something a caller chose, and
|
|
58
|
+
`JSON.parse` handed them back with `Object.prototype` behind them:
|
|
59
|
+
`state.clients['constructor']` was `Object` itself, a truthy record that is
|
|
60
|
+
not a client, and `/authorize?client_id=constructor` answered
|
|
61
|
+
`500 server_error` with a fault in the log. The maps have a null prototype
|
|
62
|
+
now; an unknown name is `undefined` on every path.
|
|
63
|
+
- **A child's words are cleaned and bounded before they reach a tool result.**
|
|
64
|
+
`list_servers` carried a child's `title` verbatim, `list_tools` and
|
|
65
|
+
`get_tool_schema` its descriptions at any length, and every failure sentence
|
|
66
|
+
quoted the child's error message as received — a bidi override reverses the
|
|
67
|
+
line, a zero-width character hides text a model still reads, an ESC sequence
|
|
68
|
+
lands in the terminal. `childText()` strips the same characters an
|
|
69
|
+
elicitation prompt loses and cuts to the field's size; schemas and
|
|
70
|
+
annotations stay verbatim by contract. The supervisor's `up (name version)`
|
|
71
|
+
and failure lines, the client id in the activity warning and the upstream's
|
|
72
|
+
error on the callback page go through `logSafe` for the same reason.
|
|
73
|
+
- **A URL-mode elicitation is forwarded only for an https page.** The client
|
|
74
|
+
opens that page under the hub's own attribution line; `javascript:`, plain
|
|
75
|
+
http, private-use schemes and credentials in the URL are dropped and counted.
|
|
76
|
+
- **A variable called `__proto__` in a sandbox secrets file vanished without a
|
|
77
|
+
word**, and an elicitation keyed `__proto__` was swallowed: on an ordinary
|
|
78
|
+
object the assignment replaces the prototype instead of adding a key. Both
|
|
79
|
+
maps are null-prototype now.
|
|
80
|
+
- **Rotated upstream secrets and headers were never applied** to a running
|
|
81
|
+
hub: the credential manager was keyed on a fingerprint that deliberately
|
|
82
|
+
survives a secret rotation, so the old configuration stayed alive in memory
|
|
83
|
+
until a restart. The manager is rebuilt when the configuration changes; the
|
|
84
|
+
stored tokens survive, as they should.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- **The nightly end-to-end suite had not run since the move to vitest 5.**
|
|
89
|
+
vitest 5 removed the `vitest/reporters` subpath; the budget reporter imported
|
|
90
|
+
its `Reporter` type from there, so `typecheck:e2e` failed before a single
|
|
91
|
+
test started, on every tier (#58). The type now comes from `vitest/node`.
|
|
92
|
+
- `SOCKET_MODE` is validated as three or four octal digits; `abc` used to reach
|
|
93
|
+
`chmodSync` as `NaN` and end the proxy with a stack trace.
|
|
94
|
+
- A `CIMD_ALLOWED_ORIGINS` entry that does not look like an origin is described
|
|
95
|
+
by its length in the startup error rather than printed — it sits a few lines
|
|
96
|
+
from `PASSWORD_HASH` in every compose file.
|
|
97
|
+
- The subscription debounce window flushes early once it holds 1024 distinct
|
|
98
|
+
events, instead of growing with the resource URIs a child announces.
|
|
99
|
+
- `/.well-known/mcp-hub-client/<id>.json` has a rate limit like every other
|
|
100
|
+
unauthenticated route.
|
|
101
|
+
- Four source files carried raw NUL, ESC, VT and FF bytes in string and regex
|
|
102
|
+
literals, which made git treat them as binary and hide every later change
|
|
103
|
+
from review. They are `\uXXXX` escapes now, with the same runtime meaning.
|
|
104
|
+
- Both images no longer ship yarn and corepack, which nothing in them runs,
|
|
105
|
+
and the hub image no longer copies `package-lock.json` into the runtime
|
|
106
|
+
layer, which nothing reads once the install has happened.
|
|
107
|
+
|
|
108
|
+
### Changed
|
|
109
|
+
|
|
110
|
+
- oxlint's `suspicious` category is on; 95 findings resolved (mostly
|
|
111
|
+
`Array#toSorted()` over copy-and-sort, helpers that captured nothing hoisted
|
|
112
|
+
to module scope, and un-shadowed names). The MCP Transport callbacks are set
|
|
113
|
+
through one `setTransportHandlers()` helper, and `_meta` / `_requestHandlers`
|
|
114
|
+
are allowed as protocol-defined names. No runtime behaviour changed.
|
|
115
|
+
- The documentation says what the code does: the request pipeline parses the
|
|
116
|
+
body before the per-client gate and has no per-IP limiter on MCP routes;
|
|
117
|
+
`call_tool`'s deadline is absolute by default; `/revoke` revokes the
|
|
118
|
+
presented access token; the login and consent pages live under
|
|
119
|
+
`/interaction/<uid>/`; the reserved-name lists gained `jwks`, `interaction`,
|
|
120
|
+
`session` and `userinfo`; there are ten runtime dependencies; an unusable
|
|
121
|
+
upstream token is state `unauthorized`; the ten supervisor clocks in
|
|
122
|
+
`src/timings.ts` are listed as environment variables; and the 100-entry
|
|
123
|
+
ceiling on never-approved registrations is written down.
|
|
124
|
+
|
|
125
|
+
### CI
|
|
126
|
+
|
|
127
|
+
- **A dependency bump could break the end-to-end suite without any pull
|
|
128
|
+
request noticing.** The E2E workflow ran on pull requests only for changes
|
|
129
|
+
under `e2e/` and its configs, and `ci.yml` never type-checked that tree, so
|
|
130
|
+
the vitest 5 bump was green and the nightly was the first to disagree. The
|
|
131
|
+
test job now runs `typecheck:e2e` (seconds, once per matrix), and changes to
|
|
132
|
+
`package.json` or `package-lock.json` trigger the E2E workflow on the pull
|
|
133
|
+
request itself.
|
|
134
|
+
- The unit tests are type-checked too (`typecheck:test`, `tsconfig.test.json`);
|
|
135
|
+
the twelve errors it surfaced were narrowing casts in tests.
|
|
136
|
+
- `dependency-review-action` (pinned, fails on high) checks what a pull request
|
|
137
|
+
changes in the dependency tree; `npm audit` only sees the tree as it is.
|
|
138
|
+
- The release workflow checks that the CHANGELOG has a section for the tag
|
|
139
|
+
*before* `npm publish`, not after it in the job that creates the GitHub
|
|
140
|
+
release. The nightly image build passes the same daily apt epoch `ci.yml`
|
|
141
|
+
does.
|
|
142
|
+
- The fast suite's client helpers list tools before every call, so the SDK's
|
|
143
|
+
client-side `structuredContent` check runs on every success path.
|
|
144
|
+
|
|
145
|
+
## [0.11.1] - 2026-09-06
|
|
146
|
+
|
|
147
|
+
### Security
|
|
148
|
+
|
|
149
|
+
- **The published image shipped a vulnerable `libssh2`, and no amount of
|
|
150
|
+
rescanning was going to change that.** `libssh2-1 1.10.0-3+b1` — pulled in as
|
|
151
|
+
a dependency of `git`, which the image installs so servers can be fetched
|
|
152
|
+
straight from a repository — is affected by CVE-2026-7598 and CVE-2026-58050,
|
|
153
|
+
both HIGH, both fixed in `bookworm-security` as `1.10.0-3+deb12u1` before the
|
|
154
|
+
scanner ever reported them. The fix could not reach the image because the apt
|
|
155
|
+
layer was never rebuilt: the base digest is pinned and the apt command is a
|
|
156
|
+
constant, so the layer cache answered every build with the packages installed
|
|
157
|
+
the day it was first built. The image scanned green one afternoon and red
|
|
158
|
+
three hours later from the same cached layer, and a workflow rerun reproduced
|
|
159
|
+
the finding exactly.
|
|
160
|
+
|
|
161
|
+
Both Dockerfiles now take an `APT_SECURITY_EPOCH` build argument that is
|
|
162
|
+
interpolated into the apt command itself, and CI passes the current UTC date.
|
|
163
|
+
The layer therefore expires once a day rather than never, while builds within
|
|
164
|
+
a day stay cached. The argument has to appear *in* the command — BuildKit keys
|
|
165
|
+
a `RUN` on its expanded command line, and an ARG that is merely declared
|
|
166
|
+
invalidates nothing. In the hub image the npm replacement step moved above
|
|
167
|
+
that boundary: it is the expensive one, it rots with its pins rather than with
|
|
168
|
+
time, and it needs neither apt nor the system CA store.
|
|
169
|
+
|
|
170
|
+
This is the second time the same cache has held back a Debian security fix
|
|
171
|
+
(CVE-2026-56408 in `libexpat1` was the first), which is why the remedy is a
|
|
172
|
+
mechanism rather than another rebuild.
|
|
173
|
+
|
|
174
|
+
### Changed
|
|
175
|
+
|
|
176
|
+
- The npm tarball no longer carries the `.js.map` source maps — 61 files and
|
|
177
|
+
360 kB unpacked, against 540 kB of actual code. `sourceMap` stays on and the
|
|
178
|
+
maps are still built: the integration tests and CI run `dist/index.js` out of
|
|
179
|
+
a checkout, where `src/` sits right next to it and a stack trace resolves
|
|
180
|
+
through them. In an installed package it cannot — the sources those maps point
|
|
181
|
+
at are not part of it.
|
|
182
|
+
|
|
10
183
|
## [0.11.0] - 2026-09-03
|
|
11
184
|
|
|
12
185
|
### Added
|
package/README.md
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# mcp-hub
|
|
2
2
|
|
|
3
|
+
<!-- badges: start -->
|
|
4
|
+
|
|
3
5
|
[](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/ni-c/mcp-hub)
|
|
7
|
+
<a href="https://socket.dev/npm/package/@ni-c/mcp-hub"><img src="https://socket.dev/api/badge/npm/package/@ni-c/mcp-hub" alt="Socket supply-chain report" height="20"></a>
|
|
8
|
+
[](https://glama.ai/mcp/servers/ni-c/mcp-hub)
|
|
9
|
+
<br>
|
|
4
10
|
[](https://www.npmjs.com/package/@ni-c/mcp-hub)
|
|
5
|
-
[](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)
|
|
9
|
-
[](https://mcp-hub.ni-c.de)
|
|
11
|
+
[](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)
|
|
12
|
+
<br>
|
|
13
|
+
[](https://mcp-hub.ni-c.de)
|
|
10
14
|
[](https://github.com/sponsors/ni-c)
|
|
15
|
+
<!-- badges: end -->
|
|
11
16
|
|
|
12
17
|
A dual-era [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it
|
|
13
18
|
serves many stdio MCP servers from **one container**, published over HTTPS, and speaks
|
|
@@ -112,14 +117,14 @@ replaces N containers with one process:
|
|
|
112
117
|
re-read on waking; `subscriptions: "off"` withdraws one server's right to
|
|
113
118
|
push; [details](https://mcp-hub.ni-c.de/guide/subscriptions).
|
|
114
119
|
- **Lightweight by design**: one Node process, no database (state is one JSON
|
|
115
|
-
file plus a signing key under `/data`),
|
|
120
|
+
file plus a signing key under `/data`), ten runtime dependencies, and
|
|
116
121
|
multi-arch images — a stated project goal is to run comfortably on a
|
|
117
122
|
single-board computer like a Raspberry Pi.
|
|
118
123
|
|
|
119
124
|
## Servers to run behind it
|
|
120
125
|
|
|
121
126
|
The hub is server-agnostic — it serves any stdio MCP server whose entry fits
|
|
122
|
-
Claude Code's `mcpServers` format, which is most of them. These
|
|
127
|
+
Claude Code's `mcpServers` format, which is most of them. These nineteen are
|
|
123
128
|
built and maintained alongside it, so their documentation carries the hub entry
|
|
124
129
|
you need and their tool filters line up with the hub's own `allowTools` /
|
|
125
130
|
`denyTools`:
|
|
@@ -127,7 +132,9 @@ you need and their tool filters line up with the hub's own `allowTools` /
|
|
|
127
132
|
| Server | npm | What it reaches |
|
|
128
133
|
| ---------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
|
|
129
134
|
| [audiobookshelf-mcp](https://audiobookshelf-mcp.ni-c.de) | `audiobookshelf-mcp` | Audiobookshelf — libraries, listening progress, collections and playlists |
|
|
135
|
+
| [caldav-mcp](https://caldav-mcp.ni-c.de) | `@ni-c/caldav-mcp` | CalDAV — events, tasks and journal entries on any server that speaks it |
|
|
130
136
|
| [calibreweb-mcp](https://calibreweb-mcp.ni-c.de) | `calibreweb-mcp` | Calibre-Web — read-only library access through the OPDS feed |
|
|
137
|
+
| [carddav-mcp](https://carddav-mcp.ni-c.de) | `@ni-c/carddav-mcp` | CardDAV — contacts, groups and photos on any server that speaks it |
|
|
131
138
|
| [freshrss-mcp](https://freshrss-mcp.ni-c.de) | `@ni-c/freshrss-mcp` | FreshRSS — feeds, categories and articles as plain text, not stream ids |
|
|
132
139
|
| [google-search-console-mcp](https://google-search-console-mcp.ni-c.de) | `@ni-c/google-search-console-mcp` | Google Search Console — properties, sitemaps, search analytics, URL inspection |
|
|
133
140
|
| [healthchecks-mcp](https://healthchecks-mcp.ni-c.de) | `healthchecks-mcp` | Healthchecks — cron and uptime checks, and why one failed |
|
|
@@ -146,7 +153,7 @@ you need and their tool filters line up with the hub's own `allowTools` /
|
|
|
146
153
|
|
|
147
154
|
Each one runs perfectly well on its own over stdio. Put them behind the hub when
|
|
148
155
|
you want them reachable from a client that cannot spawn a local process, or when
|
|
149
|
-
you would rather register one connector than
|
|
156
|
+
you would rather register one connector than nineteen.
|
|
150
157
|
|
|
151
158
|
## Configuration
|
|
152
159
|
|
|
@@ -197,7 +204,8 @@ working. `allowTools` / `denyTools` cut finer and apply to every kind of
|
|
|
197
204
|
server: a filtered tool is absent from both `tools/list` and `/hub`, and is
|
|
198
205
|
refused if called anyway — before the server is woken.
|
|
199
206
|
Reserved names: `mcp`, `hub`, `authorize`, `token`, `register`,
|
|
200
|
-
`login`, `consent`, `health`, `livez`, `revoke`, `
|
|
207
|
+
`login`, `consent`, `health`, `livez`, `revoke`, `jwks`, `interaction`,
|
|
208
|
+
`session`, `userinfo`, `upstream`, `.well-known`.
|
|
201
209
|
|
|
202
210
|
All stdio children share the hub's Unix user and can read its mounted files.
|
|
203
211
|
Only install fully trusted stdio servers. A server with a different trust level
|
|
@@ -230,6 +238,13 @@ USER node
|
|
|
230
238
|
|
|
231
239
|
### Environment
|
|
232
240
|
|
|
241
|
+
Without a non-empty `PASSWORD` or a bcrypt `PASSWORD_HASH` the HTTP hub still
|
|
242
|
+
starts, but its login is disabled: the startup log says so, the sign-in page
|
|
243
|
+
answers `503` with the reason, and no client can be approved — so no token can
|
|
244
|
+
be issued. A configured hash takes precedence, and a hash that is not a bcrypt
|
|
245
|
+
hash disables the login the same way rather than falling back to `PASSWORD`.
|
|
246
|
+
The local `--stdio` mode uses neither variable.
|
|
247
|
+
|
|
233
248
|
| Variable | Required | Description |
|
|
234
249
|
| ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
235
250
|
| `EXTERNAL_URL` | yes | Public base URL, e.g. `https://mcp.example.net` (no path) |
|
|
@@ -417,7 +432,7 @@ immediately. Per-client recipes:
|
|
|
417
432
|
| `/hub` | Bearer | aggregate endpoint with the 6 meta-tools |
|
|
418
433
|
| `/livez` | none | minimal process liveness (`200`) |
|
|
419
434
|
| `/health` | Bearer | per-server status (`200` all up / `503` degraded) |
|
|
420
|
-
| `/authorize`, `/token`, `/register`, `/
|
|
435
|
+
| `/authorize`, `/token`, `/register`, `/revoke`, `/interaction/<uid>/…` | — | OAuth 2.1 · CIMD + DCR; the login and consent pages live under `/interaction/` |
|
|
421
436
|
| `/register/<client_id>` | registration token | RFC 7592: a client reads, changes or removes its own registration |
|
|
422
437
|
| `/upstream/callback` | signed state + hub session | where an upstream returns after `upstream login` |
|
|
423
438
|
| `/.well-known/mcp-hub-client/<id>.json` | none | the hub's own client metadata document, one per `cimd` upstream |
|
|
@@ -441,7 +456,7 @@ immediately. Per-client recipes:
|
|
|
441
456
|
rotate; replaying one that was already rotated away is treated as a leak and
|
|
442
457
|
revokes the whole grant, access tokens included.
|
|
443
458
|
- Upstream auth is fully decoupled from the hub's own OAuth: an expired
|
|
444
|
-
upstream token just marks that one server `
|
|
459
|
+
upstream token just marks that one server `unauthorized` (503 on its path, visible
|
|
445
460
|
in `/health`) — clients never see the upstream's 401.
|
|
446
461
|
- One login can approve multiple connectors, but each token is valid only for
|
|
447
462
|
its requested server or `/hub`. Registration remains open as the MCP
|
package/dist/admin.js
CHANGED
|
@@ -31,6 +31,10 @@ function usage() {
|
|
|
31
31
|
].join('\n'));
|
|
32
32
|
process.exit(2);
|
|
33
33
|
}
|
|
34
|
+
function fail(message) {
|
|
35
|
+
console.error(message);
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
34
38
|
function flag(args, name) {
|
|
35
39
|
const index = args.indexOf(`--${name}`);
|
|
36
40
|
if (index === -1)
|
|
@@ -238,10 +242,6 @@ if (group === 'upstream') {
|
|
|
238
242
|
// value the hub recorded at boot is normally the one that applies.
|
|
239
243
|
const externalUrl = process.env.EXTERNAL_URL ?? store.getExternalUrl();
|
|
240
244
|
const name = rest.find(argument => !argument.startsWith('--'));
|
|
241
|
-
const fail = message => {
|
|
242
|
-
console.error(message);
|
|
243
|
-
process.exit(2);
|
|
244
|
-
};
|
|
245
245
|
if (action === 'list' || action === 'status') {
|
|
246
246
|
try {
|
|
247
247
|
console.log(JSON.stringify(upstreamStatus(store, config, action === 'status' ? name : undefined), null, 2));
|
package/dist/auth/address.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function resolvePublicAddress(hostname, allowPrivate = false) {
|
|
|
79
79
|
addresses = await dns.lookup(hostname, { all: true });
|
|
80
80
|
}
|
|
81
81
|
catch (error) {
|
|
82
|
-
throw new Error(`cannot resolve ${hostname}: ${error.message}
|
|
82
|
+
throw new Error(`cannot resolve ${hostname}: ${error.message}`, { cause: error });
|
|
83
83
|
}
|
|
84
84
|
if (addresses.some(entry => isPrivateAddress(entry.address))) {
|
|
85
85
|
throw new Error(`${hostname} resolves to a private address`);
|
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import bcrypt from 'bcryptjs';
|
|
3
1
|
import express, { Router } from 'express';
|
|
4
2
|
import { renderConsentPage } from '../consent-page.js';
|
|
5
3
|
import { allowFormActionTo, authSecurityHeaders } from '../headers.js';
|
|
6
4
|
import { renderLoginPage } from '../login-page.js';
|
|
7
5
|
import { earlyRateLimit, LoginRateLimiter } from '../rate-limit.js';
|
|
6
|
+
import { operatorCredential } from '../password.js';
|
|
8
7
|
import { isLoopbackOnly } from '../redirect-uri.js';
|
|
9
|
-
import { createSessionCookie, csrfToken, readSessionCookie,
|
|
8
|
+
import { createSessionCookie, csrfToken, readSessionCookie, SESSION_TTL_MS, sessionCookieName, verifyCsrfToken } from '../session.js';
|
|
10
9
|
import { logSafe } from '../text.js';
|
|
11
10
|
import { HUB_ACCOUNT_ID } from './provider.js';
|
|
11
|
+
/** Express 5 forwards a rejected handler promise on its own; this makes that explicit and typed. */
|
|
12
|
+
const asyncHandler = (handler) => (req, res, next) => {
|
|
13
|
+
handler(req, res, next).catch(next);
|
|
14
|
+
};
|
|
15
|
+
function expired(res, status, message) {
|
|
16
|
+
res.status(status).type('html').send(`<p>${message}</p>`);
|
|
17
|
+
}
|
|
12
18
|
/**
|
|
13
19
|
* The hub's own login and consent pages, driven by oidc-provider's interaction
|
|
14
20
|
* loop instead of by HubOAuthProvider's hand-rolled one.
|
|
@@ -40,13 +46,11 @@ export function createOidcInteractionRoutes(options) {
|
|
|
40
46
|
// the single page that has a redirect target to allow.
|
|
41
47
|
router.use(authSecurityHeaders);
|
|
42
48
|
const secure = new URL(options.externalUrl).protocol === 'https:';
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
|
|
49
|
-
};
|
|
49
|
+
// Missing or unusable means nobody signs in — never that anybody does. The
|
|
50
|
+
// empty-buffer comparison this replaced let an empty form field through when
|
|
51
|
+
// no password was configured, which approved the client and minted tokens.
|
|
52
|
+
const credential = operatorCredential(options);
|
|
53
|
+
const disabledNotice = 'Sign-in is disabled: this hub has no usable operator password configured.';
|
|
50
54
|
/** What the page may say about who is asking, and what it must not claim. */
|
|
51
55
|
const identityOf = async (clientId, resource) => {
|
|
52
56
|
const client = await provider.Client.find(clientId);
|
|
@@ -60,16 +64,13 @@ export function createOidcInteractionRoutes(options) {
|
|
|
60
64
|
loopbackOnly: isLoopbackOnly(metadata?.redirect_uris)
|
|
61
65
|
};
|
|
62
66
|
};
|
|
63
|
-
const expired = (res, status, message) => {
|
|
64
|
-
res.status(status).type('html').send(`<p>${message}</p>`);
|
|
65
|
-
};
|
|
66
67
|
// The two POSTs below carry a limiter and this GET did not, which is the
|
|
67
68
|
// asymmetry CodeQL noticed and it was right. The page is unauthenticated by
|
|
68
69
|
// construction — the uid is all a caller has — and rendering it costs a
|
|
69
70
|
// provider store lookup, so a flood is cheap to send and not free to serve.
|
|
70
71
|
// The same window as its siblings; a person opening a login page a hundred
|
|
71
72
|
// times in fifteen minutes has a different problem.
|
|
72
|
-
router.get('/interaction/:uid', earlyRateLimit(15 * 60_000, 100, 500), async (req, res
|
|
73
|
+
router.get('/interaction/:uid', earlyRateLimit(15 * 60_000, 100, 500), asyncHandler(async (req, res) => {
|
|
73
74
|
try {
|
|
74
75
|
const details = await provider.interactionDetails(req, res);
|
|
75
76
|
const params = details.params;
|
|
@@ -77,10 +78,13 @@ export function createOidcInteractionRoutes(options) {
|
|
|
77
78
|
const identity = await identityOf(String(params.client_id), params.resource);
|
|
78
79
|
allowFormActionTo(res, redirectUri);
|
|
79
80
|
if (details.prompt.name === 'login') {
|
|
80
|
-
res
|
|
81
|
+
res
|
|
82
|
+
.status(credential.enabled ? 200 : 503)
|
|
83
|
+
.type('html')
|
|
84
|
+
.send(renderLoginPage(details.uid, redirectUri, identity, credential.enabled ? undefined : disabledNotice));
|
|
81
85
|
return;
|
|
82
86
|
}
|
|
83
|
-
const session = readSessionCookie(req.headers.cookie, store.cookieSecret);
|
|
87
|
+
const session = readSessionCookie(req.headers.cookie, store.cookieSecret, secure);
|
|
84
88
|
if (!session) {
|
|
85
89
|
expired(res, 401, 'Session expired. Close this window and connect again.');
|
|
86
90
|
return;
|
|
@@ -94,10 +98,9 @@ export function createOidcInteractionRoutes(options) {
|
|
|
94
98
|
// interactionDetails throws for an unknown or expired uid, which is not
|
|
95
99
|
// an error worth a stack trace: the window was left open too long.
|
|
96
100
|
expired(res, 400, 'Authorization request expired. Close this window and connect again.');
|
|
97
|
-
void next;
|
|
98
101
|
}
|
|
99
|
-
});
|
|
100
|
-
router.post('/interaction/:uid/login', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), async (req, res) => {
|
|
102
|
+
}));
|
|
103
|
+
router.post('/interaction/:uid/login', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), asyncHandler(async (req, res) => {
|
|
101
104
|
const ip = req.ip ?? 'unknown';
|
|
102
105
|
const { password, request } = req.body;
|
|
103
106
|
let details;
|
|
@@ -119,7 +122,15 @@ export function createOidcInteractionRoutes(options) {
|
|
|
119
122
|
}
|
|
120
123
|
const params = details.params;
|
|
121
124
|
const redirectUri = String(params.redirect_uri ?? '');
|
|
122
|
-
if (
|
|
125
|
+
if (!credential.enabled) {
|
|
126
|
+
// Not a failed guess: there is nothing to guess. Counting it would let
|
|
127
|
+
// a misconfigured hub fill the fail2ban log with its own visitors.
|
|
128
|
+
const identity = await identityOf(String(params.client_id), params.resource);
|
|
129
|
+
allowFormActionTo(res, redirectUri);
|
|
130
|
+
res.status(503).type('html').send(renderLoginPage(details.uid, redirectUri, identity, disabledNotice));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (typeof password !== 'string' || !credential.check(password)) {
|
|
123
134
|
rateLimiter.recordFailure(ip);
|
|
124
135
|
console.warn(`mcp-hub: authentication failure from ${logSafe(ip)}`);
|
|
125
136
|
const identity = await identityOf(String(params.client_id), params.resource);
|
|
@@ -135,7 +146,11 @@ export function createOidcInteractionRoutes(options) {
|
|
|
135
146
|
const clientName = client?.metadata()?.client_name;
|
|
136
147
|
store.saveApproval(clientId, redirectUri, typeof clientName === 'string' ? clientName : undefined);
|
|
137
148
|
console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(redirectUri)}`);
|
|
138
|
-
|
|
149
|
+
// `__Host-` behind HTTPS: the browser then refuses the cookie from any
|
|
150
|
+
// other origin, path or domain, so nobody can fix a session into this
|
|
151
|
+
// browser from a sibling host. No `domain`, and `path: '/'`, are what the
|
|
152
|
+
// prefix requires.
|
|
153
|
+
res.cookie(sessionCookieName(secure), createSessionCookie(store.cookieSecret), {
|
|
139
154
|
httpOnly: true,
|
|
140
155
|
secure,
|
|
141
156
|
sameSite: 'lax',
|
|
@@ -143,8 +158,8 @@ export function createOidcInteractionRoutes(options) {
|
|
|
143
158
|
path: '/'
|
|
144
159
|
});
|
|
145
160
|
await provider.interactionFinished(req, res, { login: { accountId: HUB_ACCOUNT_ID } }, { mergeWithLastSubmission: false });
|
|
146
|
-
});
|
|
147
|
-
router.post('/interaction/:uid/consent', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), async (req, res) => {
|
|
161
|
+
}));
|
|
162
|
+
router.post('/interaction/:uid/consent', earlyRateLimit(15 * 60_000, 100, 500), express.urlencoded({ extended: false }), asyncHandler(async (req, res) => {
|
|
148
163
|
const { request, csrf, action } = req.body;
|
|
149
164
|
let details;
|
|
150
165
|
try {
|
|
@@ -158,7 +173,7 @@ export function createOidcInteractionRoutes(options) {
|
|
|
158
173
|
expired(res, 400, 'Authorization request expired. Close this window and connect again.');
|
|
159
174
|
return;
|
|
160
175
|
}
|
|
161
|
-
const session = readSessionCookie(req.headers.cookie, store.cookieSecret);
|
|
176
|
+
const session = readSessionCookie(req.headers.cookie, store.cookieSecret, secure);
|
|
162
177
|
if (!session) {
|
|
163
178
|
expired(res, 401, 'Session expired. Close this window and connect again.');
|
|
164
179
|
return;
|
|
@@ -181,7 +196,7 @@ export function createOidcInteractionRoutes(options) {
|
|
|
181
196
|
// The grant itself is minted by loadExistingGrant on the resumed request,
|
|
182
197
|
// which is the same code path an already-approved client takes.
|
|
183
198
|
await provider.interactionFinished(req, res, { consent: {} }, { mergeWithLastSubmission: true });
|
|
184
|
-
});
|
|
199
|
+
}));
|
|
185
200
|
return router;
|
|
186
201
|
}
|
|
187
202
|
//# sourceMappingURL=interactions.js.map
|
|
@@ -157,6 +157,18 @@ export function buildOidcProvider(store, options) {
|
|
|
157
157
|
* and an authentication mechanism nobody uses is only an attack surface.
|
|
158
158
|
*/
|
|
159
159
|
clientAuthMethods: ['client_secret_post', 'none', 'private_key_jwt'],
|
|
160
|
+
/**
|
|
161
|
+
* PKCE for every client, not only the public ones.
|
|
162
|
+
*
|
|
163
|
+
* oidc-provider's default requires it when `token_endpoint_auth_method`
|
|
164
|
+
* is `none` and lets a confidential client skip it. OAuth 2.1 and the MCP
|
|
165
|
+
* authorization specification require it of all clients, the discovery
|
|
166
|
+
* document and docs/reference/standards.md have said "S256 required"
|
|
167
|
+
* since the first release — and a confidential client's secret proves who
|
|
168
|
+
* redeems a code, not that the redeemer is the one who asked for it. The
|
|
169
|
+
* challenge is what binds the two halves of the flow.
|
|
170
|
+
*/
|
|
171
|
+
pkce: { required: () => true },
|
|
160
172
|
// The set the hub advertised before. oidc-provider's default is narrower
|
|
161
173
|
// (one algorithm per family), which would refuse a private_key_jwt client
|
|
162
174
|
// that signs with RS384 -- something the old document promised to accept.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
/**
|
|
4
|
+
* The one operator credential the HTTP hub has, and what to do when it is
|
|
5
|
+
* missing or unusable.
|
|
6
|
+
*
|
|
7
|
+
* The answer is "nobody can sign in", not "refuse to start". Both keep every
|
|
8
|
+
* token out of reach — approving a client is the only way to one, and approval
|
|
9
|
+
* needs the password — so the security outcome is the same. What differs is
|
|
10
|
+
* what an operator, an inspector or a health check sees: a container that
|
|
11
|
+
* boots, serves `/livez` and the discovery documents, and says loudly in its
|
|
12
|
+
* log and on its login page why nobody can get further, rather than a process
|
|
13
|
+
* that exits before it has said anything. Directory crawlers that start the
|
|
14
|
+
* image without a password to see what it offers (Glama does) fall in the
|
|
15
|
+
* same category as a health check: they need the process, not the login.
|
|
16
|
+
*
|
|
17
|
+
* `PASSWORD_HASH` wins over `PASSWORD` when both are set, and a hash that is
|
|
18
|
+
* not a bcrypt hash disables the login rather than falling back to the
|
|
19
|
+
* plain-text sibling: a hash was configured on purpose, and silently using the
|
|
20
|
+
* weaker variable instead would be a surprise in the wrong direction.
|
|
21
|
+
*/
|
|
22
|
+
/** `$2a$`, `$2b$` or `$2y$`, a cost of 04–31, then 53 characters of salt+digest. */
|
|
23
|
+
const BCRYPT_HASH = /^\$2[aby]\$(?:0[4-9]|[12]\d|3[01])\$[./A-Za-z0-9]{53}$/;
|
|
24
|
+
export function operatorCredential(options) {
|
|
25
|
+
const hash = options.passwordHash?.trim();
|
|
26
|
+
if (hash) {
|
|
27
|
+
if (!BCRYPT_HASH.test(hash)) {
|
|
28
|
+
return disabled(`PASSWORD_HASH is not a bcrypt hash (expected $2a$, $2b$ or $2y$, a cost and 53 characters, ${hash.length} characters given) — the operator login is disabled, no client can be approved`);
|
|
29
|
+
}
|
|
30
|
+
return { enabled: true, check: password => bcrypt.compareSync(password, hash) };
|
|
31
|
+
}
|
|
32
|
+
// Trimmed for the test only: a value of whitespace is an unset variable that
|
|
33
|
+
// somebody quoted, not a password. The comparison itself is on the raw value.
|
|
34
|
+
const password = options.password ?? '';
|
|
35
|
+
if (password.trim().length === 0) {
|
|
36
|
+
return disabled('neither PASSWORD_HASH nor PASSWORD is set — the operator login is disabled, no client can be approved');
|
|
37
|
+
}
|
|
38
|
+
const expected = Buffer.from(password);
|
|
39
|
+
return {
|
|
40
|
+
enabled: true,
|
|
41
|
+
check: given => {
|
|
42
|
+
const buffer = Buffer.from(given);
|
|
43
|
+
return buffer.length === expected.length && crypto.timingSafeEqual(buffer, expected);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function disabled(problem) {
|
|
48
|
+
return { enabled: false, problem, check: () => false };
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=password.js.map
|
|
@@ -28,6 +28,36 @@ export function pinnedLookup(address) {
|
|
|
28
28
|
done(null, address, family);
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
/** Apply the same byte ceiling to native fetch, including decoded/compressed
|
|
32
|
+
* bodies and error responses. Literal IPs and private upstreams use this path. */
|
|
33
|
+
export async function boundedResponse(response, maxBytes) {
|
|
34
|
+
const reader = response.body?.getReader();
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
try {
|
|
38
|
+
if (Number(response.headers.get('content-length')) > maxBytes) {
|
|
39
|
+
throw new Error(`response exceeds ${maxBytes} bytes`);
|
|
40
|
+
}
|
|
41
|
+
if (!reader)
|
|
42
|
+
return response;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
break;
|
|
47
|
+
size += value.byteLength;
|
|
48
|
+
if (size > maxBytes)
|
|
49
|
+
throw new Error(`response exceeds ${maxBytes} bytes`);
|
|
50
|
+
chunks.push(value);
|
|
51
|
+
}
|
|
52
|
+
const headers = new Headers(response.headers);
|
|
53
|
+
headers.delete('content-encoding');
|
|
54
|
+
headers.delete('content-length');
|
|
55
|
+
return new Response(Buffer.concat(chunks), { status: response.status, statusText: response.statusText, headers });
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await reader?.cancel().catch(() => { });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
31
61
|
export async function guardedRequest(url, options) {
|
|
32
62
|
// Only https reaches this in production — the caller rejects anything else
|
|
33
63
|
// before resolving. Plain http is here so the transport can be exercised
|
|
@@ -40,9 +40,6 @@ export function createRegistrationManagementRoutes(options) {
|
|
|
40
40
|
// can register; these routes sit in front of it and need their own. Opening
|
|
41
41
|
// them is safe: the credential is a bearer token in a header, never a cookie,
|
|
42
42
|
// so no browser attaches it to a cross-site request on its own.
|
|
43
|
-
const allowCrossOrigin = (res) => {
|
|
44
|
-
res.set('Access-Control-Allow-Origin', '*');
|
|
45
|
-
};
|
|
46
43
|
router.options(path, limit, (_req, res) => {
|
|
47
44
|
allowCrossOrigin(res);
|
|
48
45
|
res.set({
|
|
@@ -137,10 +134,13 @@ export function createRegistrationManagementRoutes(options) {
|
|
|
137
134
|
});
|
|
138
135
|
return router;
|
|
139
136
|
}
|
|
137
|
+
function allowCrossOrigin(res) {
|
|
138
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
139
|
+
}
|
|
140
140
|
/** Order is not part of the meaning of a redirect URI list. */
|
|
141
141
|
function sameUris(before, after) {
|
|
142
|
-
const a =
|
|
143
|
-
const b =
|
|
142
|
+
const a = (before ?? []).toSorted();
|
|
143
|
+
const b = (after ?? []).toSorted();
|
|
144
144
|
return a.length === b.length && a.every((uri, index) => uri === b[index]);
|
|
145
145
|
}
|
|
146
146
|
//# sourceMappingURL=registration.js.map
|