@ni-c/mcp-hub 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +231 -0
- package/README.md +114 -54
- package/dist/admin.js +231 -7
- package/dist/admin.js.map +1 -1
- package/dist/auth/address.js +89 -0
- package/dist/auth/address.js.map +1 -0
- package/dist/auth/cimd.js +367 -0
- package/dist/auth/cimd.js.map +1 -0
- package/dist/auth/consent-page.js +4 -7
- package/dist/auth/consent-page.js.map +1 -1
- package/dist/auth/login-page.js +4 -7
- package/dist/auth/login-page.js.map +1 -1
- package/dist/auth/page.js +22 -0
- package/dist/auth/page.js.map +1 -1
- package/dist/auth/pinned-fetch.js +129 -0
- package/dist/auth/pinned-fetch.js.map +1 -0
- package/dist/auth/private-key-jwt.js +213 -0
- package/dist/auth/private-key-jwt.js.map +1 -0
- package/dist/auth/provider.js +102 -45
- package/dist/auth/provider.js.map +1 -1
- package/dist/auth/rate-limit.js +43 -0
- package/dist/auth/rate-limit.js.map +1 -0
- package/dist/auth/redirect-uri.js +58 -0
- package/dist/auth/redirect-uri.js.map +1 -0
- package/dist/auth/registration.js +146 -0
- package/dist/auth/registration.js.map +1 -0
- package/dist/auth/routes.js +67 -41
- package/dist/auth/routes.js.map +1 -1
- package/dist/auth/signed-token.js +49 -0
- package/dist/auth/signed-token.js.map +1 -0
- package/dist/auth/store.js +357 -5
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/text.js +51 -0
- package/dist/auth/text.js.map +1 -0
- package/dist/config.js +130 -4
- package/dist/config.js.map +1 -1
- package/dist/health.js +22 -3
- package/dist/health.js.map +1 -1
- package/dist/hub.js +28 -0
- package/dist/hub.js.map +1 -1
- package/dist/index.js +123 -10
- package/dist/index.js.map +1 -1
- package/dist/proxy.js +25 -3
- package/dist/proxy.js.map +1 -1
- package/dist/stdio.js +27 -2
- package/dist/stdio.js.map +1 -1
- package/dist/supervisor.js +152 -8
- package/dist/supervisor.js.map +1 -1
- package/dist/tool-filter.js +58 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/upstream/auth.js +435 -0
- package/dist/upstream/auth.js.map +1 -0
- package/dist/upstream/login.js +94 -0
- package/dist/upstream/login.js.map +1 -0
- package/dist/upstream/provider.js +286 -0
- package/dist/upstream/provider.js.map +1 -0
- package/dist/upstream/routes.js +96 -0
- package/dist/upstream/routes.js.map +1 -0
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,237 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
<!-- #region changelog -->
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-08-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `allowTools` and `denyTools` on any server in `mcp.json` decide which of its
|
|
15
|
+
tools the hub exposes. Each entry is an exact tool name or a prefix with a
|
|
16
|
+
single trailing `*`; the allow list decides what is in and the deny list is
|
|
17
|
+
subtracted from it. They apply to every kind of server — stdio, remote, docker
|
|
18
|
+
and socket — because an upstream you do not control is the strongest case for
|
|
19
|
+
filtering one. Nothing changes for a server that sets neither.
|
|
20
|
+
|
|
21
|
+
**It is a boundary, not a tidy-up.** A filtered tool is absent from
|
|
22
|
+
`tools/list` on the server's own path and from `list_tools` on `/hub`, and a
|
|
23
|
+
client that calls it anyway is refused on both routes — before the server is
|
|
24
|
+
woken, so a forbidden name cannot cost a container start. The refusal is the
|
|
25
|
+
same "unknown tool" a server gives for a name it never had: `/hub` tokens go
|
|
26
|
+
to third-party connectors, and enumerating what was hidden would be a
|
|
27
|
+
disclosure in itself.
|
|
28
|
+
|
|
29
|
+
Unlike ni-c's own MCP servers, an entry that matches no tool is not a config
|
|
30
|
+
error — the hub only learns an upstream's tools once it has connected. The
|
|
31
|
+
supervisor logs it at the moment it filters, and `/health` carries `exposed`,
|
|
32
|
+
`hidden` and `unmatched` per filtered server. The latter two only once the
|
|
33
|
+
server has really listed its tools: a snapshot restored from the tool cache is
|
|
34
|
+
already filtered, so `/health` omits them rather than reporting a zero it did
|
|
35
|
+
not earn.
|
|
36
|
+
|
|
37
|
+
Filters tools only: resources, resource templates and prompts on a per-server
|
|
38
|
+
path are untouched. It also does not shrink what the hub accepts — the size
|
|
39
|
+
limits on a `tools/list` answer are measured against the raw upstream, so a
|
|
40
|
+
server that blows them still fails as a whole.
|
|
41
|
+
|
|
42
|
+
- **Client ID Metadata Documents (CIMD), the registration mechanism the MCP
|
|
43
|
+
specification now prefers.** A client may use an HTTPS URL as its `client_id`
|
|
44
|
+
and host its own metadata there; the hub fetches that document, checks that it
|
|
45
|
+
vouches for itself and takes the client's name and redirect URIs from it.
|
|
46
|
+
Nothing is registered and nothing is stored, so a client that reinstalls or
|
|
47
|
+
moves to another machine is still recognised as the same client, and the
|
|
48
|
+
approval you gave it still holds. Dynamic registration remains available and
|
|
49
|
+
advertised beside it, so nothing that works today stops working: a
|
|
50
|
+
spec-compliant client picks CIMD on its own, everything else falls back.
|
|
51
|
+
Closes [#18](https://github.com/ni-c/mcp-hub/issues/18).
|
|
52
|
+
|
|
53
|
+
- **`private_key_jwt` client authentication.** A CIMD client cannot hold a
|
|
54
|
+
shared secret, so a confidential one proves itself with a JWT signed by a key
|
|
55
|
+
it publishes in its own document (`jwks` or `jwks_uri`). This is the path
|
|
56
|
+
ChatGPT's connectors take; without it they were refused with `invalid_client`.
|
|
57
|
+
The assertion must name the client as both `iss` and `sub`, target the token
|
|
58
|
+
endpoint or the issuer, carry a `jti` that is accepted exactly once, and
|
|
59
|
+
expire within five minutes.
|
|
60
|
+
|
|
61
|
+
- **`CLIENT_REGISTRATION`** names the mechanisms a client may use to obtain a
|
|
62
|
+
`client_id` — `cimd`, `dcr`, or both, which is the default. Dropping `dcr`
|
|
63
|
+
removes `registration_endpoint` from the discovery document and makes
|
|
64
|
+
`/register` answer `404`, which is how you retire dynamic registration once
|
|
65
|
+
every client you use supports CIMD. `CIMD_ALLOWED_ORIGINS` restricts which
|
|
66
|
+
origins may serve a metadata document (only origins can be pinned — ChatGPT's
|
|
67
|
+
per-connector document path is random), and `CIMD_ALLOW_PRIVATE_ADDRESSES`
|
|
68
|
+
relaxes the SSRF guard for local development only.
|
|
69
|
+
|
|
70
|
+
- **The authorization page names what cannot be forged.** For a
|
|
71
|
+
metadata-document client it shows the document URL under *Identified by*: the
|
|
72
|
+
name in that document is self-declared, the origin serving it is not. When
|
|
73
|
+
every redirect URI is a loopback address the page says so outright, because a
|
|
74
|
+
code sent to `http://127.0.0.1:…` could be collected by any program on that
|
|
75
|
+
machine.
|
|
76
|
+
|
|
77
|
+
- **The hub can authenticate itself to upstream MCP servers with OAuth.** A
|
|
78
|
+
remote server may carry an `oauth` block instead of a static `Authorization`
|
|
79
|
+
header, and the hub then obtains and refreshes the token itself — no
|
|
80
|
+
`mcp-remote` bridge, no token cache to babysit. It identifies itself with
|
|
81
|
+
credentials the upstream issued (`mode: "static"`, with an optional
|
|
82
|
+
`clientSecret` from `${VAR}`), by registering dynamically (`"dcr"`, RFC 7591)
|
|
83
|
+
or with its own client metadata document (`"cimd"`), and uses either the
|
|
84
|
+
`client_credentials` grant, which needs no attention at all, or
|
|
85
|
+
`authorization_code`, which needs one browser visit started with
|
|
86
|
+
`mcp-hub-admin upstream login <server>`. The CLI prints a URL, the upstream
|
|
87
|
+
redirects back to the hub, and the server connects. `upstream list`, `status`,
|
|
88
|
+
`register`, `refresh` and `logout` cover the rest; `logout` also revokes the
|
|
89
|
+
token (RFC 7009) and deletes a dynamic registration (RFC 7592) at the upstream.
|
|
90
|
+
Replaces the `mcp-remote` workaround the configuration guide used to recommend.
|
|
91
|
+
|
|
92
|
+
- **`mcp-hub-admin clients add`** issues a `client_id` and secret by hand, for a
|
|
93
|
+
client that supports neither dynamic registration nor a metadata document —
|
|
94
|
+
the one case that previously had no answer but an API token. Creating it
|
|
95
|
+
counts as approving it for the redirect URI you named, and it is exempt from
|
|
96
|
+
the lifecycle rules: nothing removes it but `clients delete`.
|
|
97
|
+
|
|
98
|
+
- **Outbound `private_key_jwt`.** An upstream can be told
|
|
99
|
+
`"clientAuth": "private_key_jwt"` and the hub signs an RFC 7523 assertion
|
|
100
|
+
instead of presenting a shared secret. The signing key lives at
|
|
101
|
+
`<DATA_PATH>/upstream-key.pem` and is deliberately not the key that signs the
|
|
102
|
+
hub's own access tokens; its public half travels with the client metadata
|
|
103
|
+
document or the registration request, which is how the upstream verifies it.
|
|
104
|
+
|
|
105
|
+
- **A client metadata document per upstream.** The hub previously published one
|
|
106
|
+
document built from the first server using `mode: "cimd"`, so a second such
|
|
107
|
+
server was registered with the first one's scopes. Each now has its own at
|
|
108
|
+
`/.well-known/mcp-hub-client/<id>.json`, where the identifier is derived from
|
|
109
|
+
the server name rather than being it — the URL is public, the names are not.
|
|
110
|
+
|
|
111
|
+
- **A remote server whose authorization is missing or refused enters a new
|
|
112
|
+
`unauthorized` state** instead of restarting every five minutes for ever. It
|
|
113
|
+
is reported in `/health` and `list_servers`, and the log names the command to
|
|
114
|
+
run. A completed login brings it up again without a restart of the hub.
|
|
115
|
+
|
|
116
|
+
- **A registration lifecycle for dynamic clients.** Anyone may register, so
|
|
117
|
+
registrations no longer stay forever: one that is never approved is dropped
|
|
118
|
+
after `DCR_PENDING_TTL_HOURS` (24), an approved one nobody has used after
|
|
119
|
+
`DCR_INACTIVE_DAYS` (90) along with its approval and refresh tokens, and the
|
|
120
|
+
store holds at most `DCR_MAX_CLIENTS` (500). Reaching the ceiling evicts the
|
|
121
|
+
oldest never-approved registrations; when every one of them has been approved
|
|
122
|
+
the newcomer is refused instead, so registering repeatedly cannot push a
|
|
123
|
+
working connector out. Opening the authorization page counts as use, so a slow
|
|
124
|
+
login is not cut short. The sweep runs at startup and every fifteen minutes,
|
|
125
|
+
and an existing state file is given a fresh clock rather than being read as
|
|
126
|
+
idle since the day each client registered. Client ID Metadata Document clients
|
|
127
|
+
are unaffected — they are never stored.
|
|
128
|
+
|
|
129
|
+
- **Clients can manage their own registration (RFC 7592).** The registration
|
|
130
|
+
response now carries `registration_access_token` and
|
|
131
|
+
`registration_client_uri`, and `GET`, `PUT` and `DELETE` on
|
|
132
|
+
`/register/<client_id>` let a client read, change or remove what it
|
|
133
|
+
registered. Only a hash of the token is stored, so it is shown exactly once.
|
|
134
|
+
`DELETE` takes the approval and every refresh token with it. Changing the
|
|
135
|
+
redirect URIs through `PUT` withdraws the approval — consent was given for a
|
|
136
|
+
destination and does not transfer to a new one — while changing a name or a
|
|
137
|
+
logo leaves it in place. A wrong token and an unknown `client_id` get the same
|
|
138
|
+
answer, so the endpoint cannot be used to enumerate registrations. None of
|
|
139
|
+
this comes from the SDK, whose registration router accepts `POST` and nothing
|
|
140
|
+
else.
|
|
141
|
+
|
|
142
|
+
- `mcp-hub-admin clients delete <client-id>` removes a registration outright,
|
|
143
|
+
where `clients revoke` withdraws access but keeps it, and
|
|
144
|
+
`mcp-hub-admin clients prune [--dry-run]` applies the lifecycle rules on
|
|
145
|
+
demand instead of waiting for the next sweep.
|
|
146
|
+
|
|
147
|
+
- `mcp-hub-admin clients list` now also lists clients that were approved
|
|
148
|
+
without ever being registered, and says which mechanism each one came in
|
|
149
|
+
through. A metadata-document client leaves no registration behind, so its
|
|
150
|
+
approval is the whole record; `clients revoke` works on it either way.
|
|
151
|
+
|
|
152
|
+
- **A `demo/` directory you can run without owning anything.**
|
|
153
|
+
`docker compose up -d` brings up a hub with three fake MCP servers —
|
|
154
|
+
weather, tickets and a small index of these docs — and
|
|
155
|
+
[the page that goes with it](https://mcp-hub.ni-c.de/guide/demo) shows how to
|
|
156
|
+
point the MCP Inspector or MCPJam at it. The servers answer from tables
|
|
157
|
+
compiled into them: no network, no filesystem, no stored state, so the same
|
|
158
|
+
call gives the same answer and nothing a visitor does outlasts the request.
|
|
159
|
+
`demo/token.sh` mints the API tokens. It exists because the first question
|
|
160
|
+
about a gateway is what it looks like from the client side, and until now the
|
|
161
|
+
only way to find out was to deploy one.
|
|
162
|
+
|
|
163
|
+
### Changed
|
|
164
|
+
|
|
165
|
+
- The README now carries the same eight badges, in the same order, as every other
|
|
166
|
+
MCP server in this family, all of them reading from npm rather than hard-coded;
|
|
167
|
+
the opening follows one shape; and the standalone "Full documentation" line is
|
|
168
|
+
gone, because the docs badge three lines above it points at the same page.
|
|
169
|
+
|
|
170
|
+
- The authorization-server metadata advertises
|
|
171
|
+
`client_id_metadata_document_supported`, and `private_key_jwt` alongside
|
|
172
|
+
`client_secret_post` and `none` in `token_endpoint_auth_methods_supported`.
|
|
173
|
+
The enriched document is served at the root path, the RFC 8414 path-inserted
|
|
174
|
+
form and the OpenID Connect discovery alias alike.
|
|
175
|
+
|
|
176
|
+
### Security
|
|
177
|
+
|
|
178
|
+
- Metadata documents are fetched from a URL an unauthenticated caller chose, so
|
|
179
|
+
the request is treated as hostile: `https` only, redirects never followed,
|
|
180
|
+
private, loopback, link-local and CGNAT addresses refused after DNS
|
|
181
|
+
resolution, a 5 kB cap enforced while reading, a 5-second timeout and a JSON
|
|
182
|
+
content type required. Documents carrying a `client_secret` or declaring a
|
|
183
|
+
symmetric authentication method are refused outright. Concurrent lookups of
|
|
184
|
+
one URL collapse into a single request, rejections are remembered for 30
|
|
185
|
+
seconds and the cache is bounded, so a `client_id` cannot be used to point the
|
|
186
|
+
hub at a third party. Every rejection answers a bare `invalid_client`; the
|
|
187
|
+
reason goes to the log only, so the admission policy cannot be mapped by
|
|
188
|
+
probing.
|
|
189
|
+
|
|
190
|
+
- **A client declaring `private_key_jwt` must present an assertion.** Client
|
|
191
|
+
authentication is driven by the stored record, and a metadata-document client
|
|
192
|
+
never has a `client_secret` — so a token request that simply omitted
|
|
193
|
+
`client_assertion` was treated as a public client and accepted on its
|
|
194
|
+
`client_id` alone. A leaked refresh token or authorization code was therefore
|
|
195
|
+
redeemable without the private key that exists to prevent exactly that. The
|
|
196
|
+
assertion is now required whenever the document declares it.
|
|
197
|
+
|
|
198
|
+
- **The connection is pinned to the address that was checked.** The SSRF guard
|
|
199
|
+
resolved the hostname and then handed the name to `fetch`, which resolved it
|
|
200
|
+
again; a zone answering differently the second time could move the request
|
|
201
|
+
onto an internal address or a cloud metadata endpoint. The vetted address is
|
|
202
|
+
now what the socket connects to, with the certificate still validated against
|
|
203
|
+
the hostname. The IPv6 forms that carry an IPv4 address (NAT64 `64:ff9b::/96`,
|
|
204
|
+
6to4 `2002::/16`) and several reserved IPv4 ranges are refused as well.
|
|
205
|
+
|
|
206
|
+
- **The `jwks_uri` fetch is capped at 64 kB.** It inherited the redirect,
|
|
207
|
+
timeout and address guards but not the size limit, and the JWKS is parsed
|
|
208
|
+
whole — an unauthenticated token request naming a document with a hostile
|
|
209
|
+
`jwks_uri` could push an unbounded body into the heap and take the hub, and
|
|
210
|
+
every MCP server it supervises, down with it. The cache of remote key sets is
|
|
211
|
+
now bounded too; entries were created before the signature was checked.
|
|
212
|
+
|
|
213
|
+
- **Untrusted values can no longer forge a log record.** A `client_id` may
|
|
214
|
+
contain newlines — the URL parser strips them, so the value passed every
|
|
215
|
+
structural check while the raw string reached the log, where each line is
|
|
216
|
+
given a valid timestamp. A forged `mcp-hub: authentication failure from …`
|
|
217
|
+
line matches the fail2ban filter this project ships, which made it possible
|
|
218
|
+
to have any address banned by sending unauthenticated requests. Client-chosen
|
|
219
|
+
values are escaped and capped at the point they enter a log line.
|
|
220
|
+
|
|
221
|
+
- **Redirect URIs are held to one rule for both registration mechanisms.**
|
|
222
|
+
Dynamic registration accepted anything outside the SDK's three-scheme
|
|
223
|
+
denylist, including a plaintext `http://` callback on a remote host, which
|
|
224
|
+
delivers the authorization code in the clear. Registration now requires
|
|
225
|
+
`https`, a loopback address, or an application-specific scheme for native
|
|
226
|
+
clients, and answers `400 invalid_client_metadata` otherwise.
|
|
227
|
+
|
|
228
|
+
- Self-declared client names are reduced to a single short line before they are
|
|
229
|
+
stored or shown. They were escaped but unbounded, so a name of several hundred
|
|
230
|
+
characters could push the redirect target and the loopback warning off the
|
|
231
|
+
consent page.
|
|
232
|
+
|
|
233
|
+
- `/health` is authenticated and bound to the `hub` resource; only `/livez` is
|
|
234
|
+
public. A stale comment claimed the opposite, which would have justified
|
|
235
|
+
exposing the deployment topology.
|
|
236
|
+
|
|
237
|
+
- Revocation markers are dropped once they are older than the longest-lived
|
|
238
|
+
refresh token they could reject. They were the one part of the state file
|
|
239
|
+
that only ever grew.
|
|
240
|
+
|
|
10
241
|
## [0.9.2] - 2026-08-24
|
|
11
242
|
|
|
12
243
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
# mcp-hub
|
|
2
2
|
|
|
3
|
-
[](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@ni-c/mcp-hub)
|
|
5
5
|
[](https://www.npmjs.com/package/@ni-c/mcp-hub)
|
|
6
|
-
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)
|
|
9
|
+
[](https://mcp-hub.ni-c.de)
|
|
10
|
+
[](https://github.com/sponsors/ni-c)
|
|
11
|
+
|
|
12
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it serves
|
|
13
|
+
many stdio MCP servers from **one container**, published over HTTPS.
|
|
14
|
+
|
|
15
|
+
Lets MCP clients that cannot spawn a local process — ChatGPT connectors, Claude on
|
|
16
|
+
the Web and in Code, Mistral Le Chat, Cursor, LibreChat and any other
|
|
17
|
+
Streamable-HTTP client — reach every server behind it, with a built-in OAuth 2.1
|
|
18
|
+
login protected by a single password, plus long-lived API tokens for clients that
|
|
19
|
+
cannot do OAuth (OpenAI Responses API, xAI API, Gemini API). Per-client recipes:
|
|
20
|
+
[client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).
|
|
18
21
|
|
|
19
22
|
<!-- <picture> is resolved against the colour scheme of the page showing it, so GitHub
|
|
20
23
|
picks the variant that matches its own theme toggle. npm strips <picture> and
|
|
@@ -28,18 +31,29 @@ recipes: [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibili
|
|
|
28
31
|
|
|
29
32
|

|
|
30
33
|
|
|
34
|
+
**Want to poke at it first?** [`demo/`](demo/) is a throwaway hub with three
|
|
35
|
+
fake servers — `docker compose up -d`, then point the
|
|
36
|
+
[MCP Inspector](https://mcp-hub.ni-c.de/guide/demo#mcp-inspector) or
|
|
37
|
+
[MCPJam](https://mcp-hub.ni-c.de/guide/demo#mcpjam) at it. Nothing to
|
|
38
|
+
configure, nothing to clean up but a volume.
|
|
39
|
+
|
|
31
40
|
## Why
|
|
32
41
|
|
|
33
42
|
Wrapping each stdio MCP server in its own auth-proxy container costs a full
|
|
34
|
-
image, an OAuth stack, a hostname and a compose stack
|
|
43
|
+
image, an OAuth stack, a hostname and a compose stack _per server_. mcp-hub
|
|
35
44
|
replaces N containers with one process:
|
|
36
45
|
|
|
37
46
|
- **Config is exactly Claude Code's `mcpServers` format** — copy entries 1:1.
|
|
38
47
|
- **Path-based routing**: `https://host/paperless`, `https://host/homeassistant`, …
|
|
39
|
-
- **`/hub` aggregate**: register a
|
|
48
|
+
- **`/hub` aggregate**: register a _single_ connector and reach every server
|
|
40
49
|
through 6 meta-tools (`list_servers`, `list_tools`, `get_tool_schema`,
|
|
41
50
|
`call_tool`, `wake_server`, `sleep_server`) without flooding the model
|
|
42
51
|
context with N×tools schemas.
|
|
52
|
+
- **Per-server tool filtering**: `allowTools` / `denyTools` on any server decide
|
|
53
|
+
which of its tools the hub exposes — exact names or `list_*` prefixes. A
|
|
54
|
+
filtered tool is hidden from `tools/list` _and_ refused if a client calls it
|
|
55
|
+
anyway, before the server is even woken, so a client holding a stale schema
|
|
56
|
+
cannot reach it.
|
|
43
57
|
- **Also without HTTP**: `mcp-hub --stdio` serves that same aggregate on
|
|
44
58
|
stdin/stdout for clients that can only spawn a local process (Claude Desktop,
|
|
45
59
|
Codex, …) — same `mcp.json`, no TLS, no reverse proxy, no login. Auth exists
|
|
@@ -48,6 +62,20 @@ replaces N containers with one process:
|
|
|
48
62
|
after 60 idle minutes, answering `initialize`/`tools/list` from a persistent
|
|
49
63
|
snapshot meanwhile — a dozen servers cost only the memory of the ones in
|
|
50
64
|
use. `keepAlive: true` exempts a server, `IDLE_TIMEOUT_MINUTES=0` the hub.
|
|
65
|
+
- **CIMD-first OAuth 2.1**: clients identify themselves with a [Client ID
|
|
66
|
+
Metadata Document](https://mcp-hub.ni-c.de/guide/client-registration) — the
|
|
67
|
+
registration-free path the MCP spec now prefers — including `private_key_jwt`
|
|
68
|
+
against the keys in their own document (metadata-document clients only). RFC
|
|
69
|
+
7591 dynamic registration stays advertised beside it for older clients,
|
|
70
|
+
`mcp-hub-admin clients add` issues credentials by hand for anything that can
|
|
71
|
+
do neither, and `CLIENT_REGISTRATION` turns either mechanism off.
|
|
72
|
+
- **OAuth outwards, too**: a remote server that speaks OAuth gets an `oauth`
|
|
73
|
+
block instead of a static header. The hub registers itself — with credentials
|
|
74
|
+
the upstream issued, via RFC 7591, or with its own client metadata document —
|
|
75
|
+
then obtains and refreshes the token. `client_credentials` upstreams need no
|
|
76
|
+
attention at all; where a person must sign in, `mcp-hub-admin upstream login`
|
|
77
|
+
prints one URL. An upstream that needs re-authorizing shows up as one server
|
|
78
|
+
`unauthorized`, not as a confusing 401 in your client.
|
|
51
79
|
- **Supervision**: children are pinged and restarted with exponential backoff
|
|
52
80
|
when they die. A down server answers 503, not silence; a crash-looping
|
|
53
81
|
server nobody uses is parked instead of restarted forever.
|
|
@@ -81,6 +109,11 @@ version in your image; do not download mutable packages at runtime:
|
|
|
81
109
|
"headers": { "Authorization": "Bearer ${HA_TOKEN}" }
|
|
82
110
|
},
|
|
83
111
|
"private-thing": { "command": "some-mcp", "args": [], "hub": false },
|
|
112
|
+
"paperless-readonly": {
|
|
113
|
+
"command": "paperless-mcp",
|
|
114
|
+
"allowTools": ["search_*", "get_document"],
|
|
115
|
+
"denyTools": ["delete_document"]
|
|
116
|
+
},
|
|
84
117
|
"untrusted": {
|
|
85
118
|
"type": "docker",
|
|
86
119
|
"image": "ghcr.io/example/untrusted-mcp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
@@ -95,13 +128,16 @@ Stdio servers (`command`/`args`/`env`) are spawned as supervised child
|
|
|
95
128
|
processes. Remote servers (`type: "http"` or `"sse"` with `url` and optional
|
|
96
129
|
`headers`) are connected as MCP clients with the configured headers injected
|
|
97
130
|
on every request — the same supervision (ping, backoff reconnect, hot reload)
|
|
98
|
-
applies.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
131
|
+
applies. An upstream that speaks OAuth gets an `oauth` block instead of a
|
|
132
|
+
header: the hub registers itself (statically, via RFC 7591 or via a client
|
|
133
|
+
metadata document), obtains the token and refreshes it, with one browser visit
|
|
134
|
+
started from the admin CLI where the grant needs a person.
|
|
102
135
|
`"hub": false` hides a server from the `/hub` aggregate; its own path keeps
|
|
103
|
-
working.
|
|
104
|
-
|
|
136
|
+
working. `allowTools` / `denyTools` cut finer and apply to every kind of
|
|
137
|
+
server: a filtered tool is absent from both `tools/list` and `/hub`, and is
|
|
138
|
+
refused if called anyway — before the server is woken.
|
|
139
|
+
Reserved names: `mcp`, `hub`, `authorize`, `token`, `register`,
|
|
140
|
+
`login`, `consent`, `health`, `livez`, `revoke`, `upstream`, `.well-known`.
|
|
105
141
|
|
|
106
142
|
All stdio children share the hub's Unix user and can read its mounted files.
|
|
107
143
|
Only install fully trusted stdio servers. A server with a different trust level
|
|
@@ -126,7 +162,7 @@ and [SECURITY.md](SECURITY.md).
|
|
|
126
162
|
For a custom image, pin every package to an exact version:
|
|
127
163
|
|
|
128
164
|
```dockerfile
|
|
129
|
-
FROM ghcr.io/ni-c/mcp-hub:0.
|
|
165
|
+
FROM ghcr.io/ni-c/mcp-hub:0.10.0 # pin @sha256:<digest> in production
|
|
130
166
|
USER root
|
|
131
167
|
RUN npm install -g your-mcp-package@1.2.3
|
|
132
168
|
USER node
|
|
@@ -134,24 +170,38 @@ USER node
|
|
|
134
170
|
|
|
135
171
|
### Environment
|
|
136
172
|
|
|
137
|
-
| Variable
|
|
138
|
-
|
|
139
|
-
| `EXTERNAL_URL`
|
|
140
|
-
| `PASSWORD_HASH`
|
|
141
|
-
| `PASSWORD`
|
|
142
|
-
| `TRUSTED_PROXIES`
|
|
143
|
-
| `RESOURCE_BOUND_TOKENS`
|
|
144
|
-
| `DEFAULT_RESOURCE`
|
|
145
|
-
| `MCP_BODY_LIMIT`
|
|
146
|
-
| `MCP_REQUESTS_PER_MINUTE`
|
|
147
|
-
| `MCP_MAX_CONCURRENT_REQUESTS`
|
|
148
|
-
| `MCP_MAX_CONCURRENT_STREAMS`
|
|
149
|
-
| `HTTP_HEADERS_TIMEOUT_MS`
|
|
150
|
-
| `HTTP_REQUEST_TIMEOUT_MS`
|
|
151
|
-
| `PORT`
|
|
152
|
-
| `CONFIG_PATH`
|
|
153
|
-
| `DATA_PATH`
|
|
154
|
-
| `LOG_FILE`
|
|
173
|
+
| Variable | Required | Description |
|
|
174
|
+
| ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
175
|
+
| `EXTERNAL_URL` | yes | Public base URL, e.g. `https://mcp.example.net` (no path) |
|
|
176
|
+
| `PASSWORD_HASH` | one of | bcrypt hash of the login password (`htpasswd -bnBC 10 "" 'pw' \| tr -d ':\n'`) |
|
|
177
|
+
| `PASSWORD` | one of | plain-text alternative to `PASSWORD_HASH` |
|
|
178
|
+
| `TRUSTED_PROXIES` | no | comma-separated IPs/CIDRs allowed to set `X-Forwarded-*` (see below) |
|
|
179
|
+
| `RESOURCE_BOUND_TOKENS` | no | RFC 8707 tokens bound to `/hub` or one `/<name>/mcp`, default `true`; set `false` only to keep pre-0.5 unbound tokens working |
|
|
180
|
+
| `DEFAULT_RESOURCE` | no | server name (or `hub`) to bind tokens to when a client sends no `resource` parameter; unset → such requests are refused |
|
|
181
|
+
| `MCP_BODY_LIMIT` | no | authenticated MCP JSON body limit, default `1mb` |
|
|
182
|
+
| `MCP_REQUESTS_PER_MINUTE` | no | limit per OAuth client, default `120` |
|
|
183
|
+
| `MCP_MAX_CONCURRENT_REQUESTS` | no | in-flight request limit per OAuth client, default `4` |
|
|
184
|
+
| `MCP_MAX_CONCURRENT_STREAMS` | no | open SSE listening streams per OAuth client — one per connected session, default `32` |
|
|
185
|
+
| `HTTP_HEADERS_TIMEOUT_MS` | no | Node HTTP header timeout, default `10000` |
|
|
186
|
+
| `HTTP_REQUEST_TIMEOUT_MS` | no | complete request timeout, default `310000` (slightly above the tool-call timeout) |
|
|
187
|
+
| `PORT` | no | listen port (default 80 in the image, 3000 outside) |
|
|
188
|
+
| `CONFIG_PATH` | no | default `/config/mcp.json` |
|
|
189
|
+
| `DATA_PATH` | no | default `/data` |
|
|
190
|
+
| `LOG_FILE` | no | additionally mirror all log output into this file, e.g. `/data/mcp-hub.log` (see below) |
|
|
191
|
+
| `CLIENT_REGISTRATION` | no | which mechanisms a client may use for a `client_id`: `cimd`, `dcr` or both (default) |
|
|
192
|
+
| `CIMD_ALLOWED_ORIGINS` | no | bare https origins whose metadata documents are accepted; unset → any |
|
|
193
|
+
| `CIMD_ALLOW_PRIVATE_ADDRESSES` | no | local development only; relaxes the SSRF guard, warns on every start |
|
|
194
|
+
| `DCR_MAX_CLIENTS` | no | ceiling on stored dynamic registrations, default `500` |
|
|
195
|
+
| `DCR_PENDING_TTL_HOURS` | no | how long a never-approved registration is kept, default `24` |
|
|
196
|
+
| `DCR_INACTIVE_DAYS` | no | how long an unused approved registration is kept, default `90` |
|
|
197
|
+
| `IDLE_TIMEOUT_MINUTES` | no | idle minutes before an on-demand server sleeps, default `60`; `0` disables it |
|
|
198
|
+
| `TOOL_CACHE_PATH` | no | snapshots of sleeping servers, default `<DATA_PATH>/tool-cache.json` |
|
|
199
|
+
| `MCP_CALL_TIMEOUT_MS` | no | deadline for one forwarded tool call, default `300000` |
|
|
200
|
+
| `MCP_RESET_TIMEOUT_ON_PROGRESS` | no | let progress notifications extend that deadline, default `false` |
|
|
201
|
+
| `DOCKER_HOST` | with docker servers | the **policy proxy's** socket; a direct daemon socket fails closed |
|
|
202
|
+
|
|
203
|
+
The full table, including what applies in stdio mode, is in the
|
|
204
|
+
[environment reference](https://mcp-hub.ni-c.de/reference/environment).
|
|
155
205
|
|
|
156
206
|
`/data` holds the Ed25519 JWT key, registered OAuth clients, approvals and
|
|
157
207
|
refresh tokens. **Mount it as a volume** — recreating it invalidates every
|
|
@@ -170,7 +220,7 @@ invalidates those unbound tokens, so every connector authorizes once more.
|
|
|
170
220
|
|
|
171
221
|
`TRUSTED_PROXIES` decides what `req.ip` is, and therefore what the login rate
|
|
172
222
|
limiter counts. List **only** your own reverse proxy, and make sure it
|
|
173
|
-
|
|
223
|
+
_overwrites_ `X-Forwarded-For` rather than appending to it — otherwise a
|
|
174
224
|
client can supply its own address and rotate it to sidestep the per-IP limit.
|
|
175
225
|
If the variable is unset, every request appears to come from the proxy and
|
|
176
226
|
per-IP limiting degrades to a single global counter (the hub logs a warning
|
|
@@ -185,7 +235,7 @@ Published on every push to `main` and every `vX.Y.Z` release tag, for
|
|
|
185
235
|
[package page](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub).
|
|
186
236
|
|
|
187
237
|
```sh
|
|
188
|
-
docker pull ghcr.io/ni-c/mcp-hub:0.
|
|
238
|
+
docker pull ghcr.io/ni-c/mcp-hub:0.10.0
|
|
189
239
|
```
|
|
190
240
|
|
|
191
241
|
Tags: `latest` (tip of `main`), `X.Y.Z` and `X.Y` (releases), and
|
|
@@ -200,7 +250,7 @@ With compose, copy the example and point it at the image instead of building:
|
|
|
200
250
|
```yaml
|
|
201
251
|
services:
|
|
202
252
|
mcp-hub:
|
|
203
|
-
image: ghcr.io/ni-c/mcp-hub:0.
|
|
253
|
+
image: ghcr.io/ni-c/mcp-hub:0.10.0 # replaces `build: .`; pin a digest in production
|
|
204
254
|
# ...rest of docker-compose.example.yml unchanged
|
|
205
255
|
```
|
|
206
256
|
|
|
@@ -222,7 +272,7 @@ docker run -d --name mcp-hub \
|
|
|
222
272
|
-e TRUSTED_PROXIES="192.168.1.0/24" \
|
|
223
273
|
-v "$PWD/config:/config:ro" \
|
|
224
274
|
-v "$PWD/data:/data" \
|
|
225
|
-
ghcr.io/ni-c/mcp-hub:0.
|
|
275
|
+
ghcr.io/ni-c/mcp-hub:0.10.0
|
|
226
276
|
```
|
|
227
277
|
|
|
228
278
|
Update to a newer image with `docker compose pull && docker compose up -d`
|
|
@@ -263,7 +313,7 @@ see [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).
|
|
|
263
313
|
|
|
264
314
|
Each client is confirmed once. Entering the password approves the client that
|
|
265
315
|
asked; while a login session is still valid, a client you have not seen before
|
|
266
|
-
gets an explicit
|
|
316
|
+
gets an explicit _Approve / Deny_ page instead of a code. Approved clients
|
|
267
317
|
reconnect silently from then on.
|
|
268
318
|
|
|
269
319
|
List clients or revoke one. The CLI shares `/data` with the running hub and
|
|
@@ -273,10 +323,17 @@ live container — a revocation takes effect on the next request:
|
|
|
273
323
|
```sh
|
|
274
324
|
docker exec mcp-hub node /app/dist/admin.js clients list
|
|
275
325
|
docker exec mcp-hub node /app/dist/admin.js clients revoke CLIENT_ID
|
|
326
|
+
docker exec mcp-hub node /app/dist/admin.js clients delete CLIENT_ID
|
|
327
|
+
docker exec mcp-hub node /app/dist/admin.js clients prune --dry-run
|
|
276
328
|
```
|
|
277
329
|
|
|
278
330
|
Revocation removes the approval and all refresh tokens and immediately rejects
|
|
279
331
|
already-issued access tokens. The next connection needs explicit approval.
|
|
332
|
+
`delete` goes further and removes the registration itself, and `prune` applies
|
|
333
|
+
the [registration lifecycle rules](https://mcp-hub.ni-c.de/guide/client-registration)
|
|
334
|
+
on demand — registrations that were never approved expire after a day, unused
|
|
335
|
+
ones after 90 days, and a dynamically registered client can also remove its own
|
|
336
|
+
registration through RFC 7592.
|
|
280
337
|
|
|
281
338
|
For clients that cannot do OAuth at all — the OpenAI Responses API, the xAI
|
|
282
339
|
API, Gemini's `mcp_server` tool, plain-header clients — the same CLI mints
|
|
@@ -294,15 +351,18 @@ immediately. Per-client recipes:
|
|
|
294
351
|
|
|
295
352
|
## Endpoints
|
|
296
353
|
|
|
297
|
-
| Path
|
|
298
|
-
|
|
299
|
-
| `/<name>`, `/<name>/mcp`
|
|
300
|
-
| `/hub`
|
|
301
|
-
| `/livez`
|
|
302
|
-
| `/health`
|
|
303
|
-
| `/authorize`, `/token`, `/register`, `/login`, `/consent`, `/revoke` | —
|
|
304
|
-
|
|
|
305
|
-
|
|
|
354
|
+
| Path | Auth | Purpose |
|
|
355
|
+
| -------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------- |
|
|
356
|
+
| `/<name>`, `/<name>/mcp` | Bearer | Streamable HTTP endpoint of one server |
|
|
357
|
+
| `/hub` | Bearer | aggregate endpoint with the 6 meta-tools |
|
|
358
|
+
| `/livez` | none | minimal process liveness (`200`) |
|
|
359
|
+
| `/health` | Bearer | per-server status (`200` all up / `503` degraded) |
|
|
360
|
+
| `/authorize`, `/token`, `/register`, `/login`, `/consent`, `/revoke` | — | OAuth 2.1 · CIMD + DCR |
|
|
361
|
+
| `/register/<client_id>` | registration token | RFC 7592: a client reads, changes or removes its own registration |
|
|
362
|
+
| `/upstream/callback` | signed state + hub session | where an upstream returns after `upstream login` |
|
|
363
|
+
| `/.well-known/mcp-hub-client/<id>.json` | none | the hub's own client metadata document, one per `cimd` upstream |
|
|
364
|
+
| `/.well-known/oauth-authorization-server[/…]` | none | RFC 8414 metadata |
|
|
365
|
+
| `/.well-known/oauth-protected-resource[/…]` | none | RFC 9728 metadata (path-scoped) |
|
|
306
366
|
|
|
307
367
|
## Notes & limitations
|
|
308
368
|
|