@ni-c/mcp-hub 0.11.3 → 0.11.4
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 +74 -0
- package/README.md +6 -5
- package/dist/admin.js +8 -3
- package/dist/auth/cimd.js +8 -2
- package/dist/auth/oidc/interactions.js +14 -3
- package/dist/auth/oidc/mount.js +6 -1
- package/dist/auth/oidc/provider.js +23 -2
- package/dist/auth/oidc/verifier.js +35 -4
- package/dist/auth/page.js +9 -3
- package/dist/auth/redirect-uri.js +40 -0
- package/dist/auth/resource.js +16 -0
- package/dist/auth/store.js +35 -4
- package/dist/auth/text.js +9 -0
- package/dist/index.js +4 -1
- package/dist/subscriptions.js +23 -0
- package/dist/supervisor.js +33 -6
- package/dist/transports/docker.js +19 -3
- package/dist/upstream/auth.js +21 -5
- package/dist/upstream/provider.js +57 -3
- package/dist/upstream/redirects.js +100 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,80 @@ 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.4] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **An approval for one server was an approval for all of them.** The login
|
|
15
|
+
and consent pages name the resource a client asks for under *Requested
|
|
16
|
+
access*, but the approval was stored for the client and its redirect URI
|
|
17
|
+
only. While the operator's session lasted, a client approved for
|
|
18
|
+
`/paperless/mcp` could ask for `/hub` and receive a code without any page —
|
|
19
|
+
a token for every server. The approval now records the resources the page
|
|
20
|
+
showed, and a request for any other one brings the page back: *Approve /
|
|
21
|
+
Deny* within a live session, the sign-in page otherwise. Approvals written
|
|
22
|
+
by an older version name no resource, so each connector sees the page once
|
|
23
|
+
more the next time it authorizes; refresh tokens are unaffected.
|
|
24
|
+
`mcp-hub-admin clients list` shows `approvedResources`.
|
|
25
|
+
- **A remote upstream's reply had no size limit.** The control plane to an
|
|
26
|
+
upstream's authorization server has capped every response since August; the
|
|
27
|
+
MCP requests themselves read whatever came back, so one `tools/call` answered
|
|
28
|
+
with 300 MiB cost the hub 630 MB of heap, and an event without a line end
|
|
29
|
+
grew without bound in the SDK's SSE parser. Replies are now held to 10 MiB,
|
|
30
|
+
the limit the byte-stream transports apply to one message — a JSON reply as a
|
|
31
|
+
whole, an event stream per event, and a declared `content-length` above it is
|
|
32
|
+
refused before reading. `resources/updated` URIs longer than
|
|
33
|
+
`MCP_SUBSCRIPTION_MAX_URI_BYTES` (8 KiB) are dropped.
|
|
34
|
+
- **The consent page could be made to show a different address.** A redirect
|
|
35
|
+
URI such as `https://claude.ai@attacker.example/cb` connects to
|
|
36
|
+
`attacker.example`, and a bidi override or zero-width character in a
|
|
37
|
+
redirect URI or metadata document URL could reorder or hide the host in the
|
|
38
|
+
line the operator reads. Both are refused now, for every scheme and both
|
|
39
|
+
registration mechanisms; percent-encoded bytes are still accepted, and
|
|
40
|
+
values stored earlier are shown with such characters as visible escapes.
|
|
41
|
+
- **`/revoke` was not rate-limited.** It runs the same client authentication as
|
|
42
|
+
`/token`, `private_key_jwt` signature checks included, and a client can
|
|
43
|
+
register itself; it now has the same budget as `/token`, counted
|
|
44
|
+
separately so a flood of one cannot starve the other.
|
|
45
|
+
- **Requests could drive a crashing server's restarts.** Every request to an
|
|
46
|
+
on-demand server cancelled its crash backoff and reset the give-up count, so
|
|
47
|
+
a client repeating a call restarted a crashing sandbox container at its own
|
|
48
|
+
pace — 30 attempts in 12 seconds instead of 4. A request now waits for the
|
|
49
|
+
scheduled restart; after a give-up the first request retries and further
|
|
50
|
+
ones within the backoff get the last error.
|
|
51
|
+
- **DPoP was offered but never checked.** oidc-provider enables it by default,
|
|
52
|
+
so a client that sent a proof received a `DPoP` token that the hub then
|
|
53
|
+
accepted as a plain bearer. It is switched off, which is what the standards
|
|
54
|
+
page already said.
|
|
55
|
+
- **Text from a sandbox or an upstream reached the terminal unescaped.** A
|
|
56
|
+
sandbox container's stderr and an upstream authorization server's error text
|
|
57
|
+
in `mcp-hub-admin upstream …` now go through the same escaping as every other
|
|
58
|
+
stranger's text, so an escape sequence or a bare CR can no longer rewrite
|
|
59
|
+
what the operator sees. `LOG_FILE` was not affected.
|
|
60
|
+
- **An upstream token with a line break broke its server for good.** A token
|
|
61
|
+
containing CR or LF was stored and then made every request to that server
|
|
62
|
+
fail in `Headers.set()`, while `/health` kept showing it up. Tokens, and the
|
|
63
|
+
RFC 7592 registration token, must now be printable ASCII of at most 16 KiB;
|
|
64
|
+
a malformed one fails the server's authorization instead of being stored,
|
|
65
|
+
and its value is never logged.
|
|
66
|
+
|
|
67
|
+
Reported by the 2026-09-24 internal review.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **A wrongly signed JWT cost a signature check.** A bearer shaped like an
|
|
72
|
+
API token is now decoded first and only verified when its algorithm,
|
|
73
|
+
subject and `jti` match a live token, which makes rejecting garbage about
|
|
74
|
+
twenty times cheaper. A valid token is still verified in full, and
|
|
75
|
+
revocation is still checked after verification.
|
|
76
|
+
- **`clients revoke` reported 0 refresh tokens.** It counted a map nothing
|
|
77
|
+
writes any more; it now counts the live ones it invalidates. The revocation
|
|
78
|
+
itself was always effective.
|
|
79
|
+
- **A character split across two docker frames was garbled** in the sandbox's
|
|
80
|
+
stderr; the stream is now decoded across frame boundaries, and a last line
|
|
81
|
+
without a newline is written out when the container stops instead of being
|
|
82
|
+
dropped.
|
|
83
|
+
|
|
10
84
|
## [0.11.3] - 2026-09-12
|
|
11
85
|
|
|
12
86
|
### Security
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml)
|
|
6
6
|
[](https://scorecard.dev/viewer/?uri=github.com/ni-c/mcp-hub)
|
|
7
|
-
|
|
7
|
+
[](https://socket.dev/npm/package/@ni-c/mcp-hub)
|
|
8
8
|
[](https://glama.ai/mcp/servers/ni-c/mcp-hub)
|
|
9
9
|
<br>
|
|
10
10
|
[](https://www.npmjs.com/package/@ni-c/mcp-hub)
|
|
@@ -386,10 +386,11 @@ and log in once with the password. Claude Code:
|
|
|
386
386
|
(OpenAI Responses API, xAI, Gemini API) use an admin-minted token instead —
|
|
387
387
|
see [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).
|
|
388
388
|
|
|
389
|
-
Each client is confirmed once. Entering the password
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
389
|
+
Each client is confirmed once per server it asks for. Entering the password
|
|
390
|
+
approves the client that asked, for the server shown on the page; while a login
|
|
391
|
+
session is still valid, a client you have not seen before — or one asking for
|
|
392
|
+
another server — gets an explicit _Approve / Deny_ page instead of a code.
|
|
393
|
+
Approved clients reconnect silently from then on.
|
|
393
394
|
|
|
394
395
|
List clients or revoke one. The CLI shares `/data` with the running hub and
|
|
395
396
|
both sides re-read the state file before they touch it, so this works against a
|
package/dist/admin.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import { AuthStore, clientLimitsFromEnv } from './auth/store.js';
|
|
4
4
|
import { isSafeRedirectUri } from './auth/redirect-uri.js';
|
|
5
|
-
import { clampDisplayName } from './auth/text.js';
|
|
5
|
+
import { clampDisplayName, logSafe } from './auth/text.js';
|
|
6
6
|
import { isClientIdMetadataUrl } from './auth/cimd.js';
|
|
7
7
|
import { mintApiToken } from './auth/api-tokens.js';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
@@ -31,8 +31,10 @@ function usage() {
|
|
|
31
31
|
].join('\n'));
|
|
32
32
|
process.exit(2);
|
|
33
33
|
}
|
|
34
|
+
/** Messages here can carry an upstream authorization server's own error
|
|
35
|
+
* text, so they are escaped before they reach the operator's terminal. */
|
|
34
36
|
function fail(message) {
|
|
35
|
-
console.error(message);
|
|
37
|
+
console.error(logSafe(message));
|
|
36
38
|
process.exit(2);
|
|
37
39
|
}
|
|
38
40
|
function flag(args, name) {
|
|
@@ -65,6 +67,7 @@ if (group === 'clients' && action === 'list') {
|
|
|
65
67
|
via: clientOrigin(id),
|
|
66
68
|
registeredRedirectUris: client.redirect_uris,
|
|
67
69
|
approvedRedirectUris: approvals[id]?.redirectUris ?? [],
|
|
70
|
+
approvedResources: approvals[id]?.resources ?? [],
|
|
68
71
|
approvedAt: approvals[id] ? new Date(approvals[id].approvedAt * 1000).toISOString() : null
|
|
69
72
|
}));
|
|
70
73
|
// Metadata-document clients are never stored — the document is fetched fresh
|
|
@@ -80,6 +83,7 @@ if (group === 'clients' && action === 'list') {
|
|
|
80
83
|
via: clientOrigin(id),
|
|
81
84
|
registeredRedirectUris: [],
|
|
82
85
|
approvedRedirectUris: approval.redirectUris,
|
|
86
|
+
approvedResources: approval.resources,
|
|
83
87
|
approvedAt: new Date(approval.approvedAt * 1000).toISOString()
|
|
84
88
|
});
|
|
85
89
|
}
|
|
@@ -324,8 +328,9 @@ if (group === 'upstream') {
|
|
|
324
328
|
// unreachable must never leave a credential behind here.
|
|
325
329
|
const problems = await auth.revokeRemotely().catch(error => [error.message]);
|
|
326
330
|
const forgotten = store.forgetUpstreamCredentials(name);
|
|
331
|
+
// A revocation failure can carry the authorization server's own text.
|
|
327
332
|
for (const problem of problems)
|
|
328
|
-
console.error(`Upstream revocation: ${problem}`);
|
|
333
|
+
console.error(`Upstream revocation: ${logSafe(problem)}`);
|
|
329
334
|
console.log(JSON.stringify({ server: name, forgotten, revokedAtUpstream: problems.length === 0 }, null, 2));
|
|
330
335
|
process.exit(problems.length > 0 ? 1 : 0);
|
|
331
336
|
}
|
package/dist/auth/cimd.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPrivateAddress, resolvePublicAddress } from './address.js';
|
|
2
2
|
import { guardedRequest } from './pinned-fetch.js';
|
|
3
|
-
import { isSafeRedirectUri } from './redirect-uri.js';
|
|
3
|
+
import { hasUserinfo, isPrintableAsciiUri, isSafeRedirectUri } from './redirect-uri.js';
|
|
4
4
|
import { clampDisplayName, logSafe } from './text.js';
|
|
5
5
|
// Lives in address.ts now — re-exported because it is part of this module's
|
|
6
6
|
// established surface and the test suite reaches for it here.
|
|
@@ -59,8 +59,14 @@ export function setCimdFetch(fn) {
|
|
|
59
59
|
* fragment, no credentials and no dot-segments. A `client_id` that fails this
|
|
60
60
|
* is not a metadata document URL at all and is looked up as a locally
|
|
61
61
|
* registered client instead — which is what keeps DCR working beside CIMD.
|
|
62
|
+
*
|
|
63
|
+
* The consent page shows this URL as the part of the client's identity that
|
|
64
|
+
* cannot be invented, so it is held to the same printable-ASCII and
|
|
65
|
+
* no-userinfo rules as a redirect URI.
|
|
62
66
|
*/
|
|
63
67
|
export function isClientIdMetadataUrl(clientId) {
|
|
68
|
+
if (!isPrintableAsciiUri(clientId))
|
|
69
|
+
return false;
|
|
64
70
|
let url;
|
|
65
71
|
try {
|
|
66
72
|
url = new URL(clientId);
|
|
@@ -70,7 +76,7 @@ export function isClientIdMetadataUrl(clientId) {
|
|
|
70
76
|
}
|
|
71
77
|
if (url.protocol !== 'https:')
|
|
72
78
|
return false;
|
|
73
|
-
if (url.hash ||
|
|
79
|
+
if (url.hash || hasUserinfo(clientId, url))
|
|
74
80
|
return false;
|
|
75
81
|
if (url.pathname === '' || url.pathname === '/')
|
|
76
82
|
return false;
|
|
@@ -5,9 +5,17 @@ import { renderLoginPage } from '../login-page.js';
|
|
|
5
5
|
import { earlyRateLimit, LoginRateLimiter } from '../rate-limit.js';
|
|
6
6
|
import { operatorCredential } from '../password.js';
|
|
7
7
|
import { isLoopbackOnly } from '../redirect-uri.js';
|
|
8
|
+
import { approvalResourceKey } from '../resource.js';
|
|
8
9
|
import { createSessionCookie, csrfToken, readSessionCookie, SESSION_TTL_MS, sessionCookieName, verifyCsrfToken } from '../session.js';
|
|
9
10
|
import { logSafe } from '../text.js';
|
|
10
11
|
import { HUB_ACCOUNT_ID } from './provider.js';
|
|
12
|
+
/** The resources the page showed under "Requested access", in the form
|
|
13
|
+
* loadExistingGrant looks an approval up by. */
|
|
14
|
+
function canonicalizeRequestedResources(resource, options) {
|
|
15
|
+
return [resource ?? []]
|
|
16
|
+
.flat()
|
|
17
|
+
.map(value => approvalResourceKey(String(value), options.externalUrl, options.resolveResource));
|
|
18
|
+
}
|
|
11
19
|
/** Express 5 forwards a rejected handler promise on its own; this makes that explicit and typed. */
|
|
12
20
|
const asyncHandler = (handler) => (req, res, next) => {
|
|
13
21
|
handler(req, res, next).catch(next);
|
|
@@ -140,11 +148,13 @@ export function createOidcInteractionRoutes(options) {
|
|
|
140
148
|
}
|
|
141
149
|
rateLimiter.reset(ip);
|
|
142
150
|
console.log(`mcp-hub: successful login from ${logSafe(ip)}`);
|
|
143
|
-
// Typing the password is the consent for the client that triggered it
|
|
151
|
+
// Typing the password is the consent for the client that triggered it:
|
|
152
|
+
// for its redirect target and for the resource the page showed.
|
|
144
153
|
const clientId = String(params.client_id);
|
|
145
154
|
const client = await provider.Client.find(clientId);
|
|
146
155
|
const clientName = client?.metadata()?.client_name;
|
|
147
|
-
|
|
156
|
+
const resources = canonicalizeRequestedResources(params.resource, options);
|
|
157
|
+
store.saveApproval(clientId, redirectUri, typeof clientName === 'string' ? clientName : undefined, resources);
|
|
148
158
|
console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(redirectUri)}`);
|
|
149
159
|
// `__Host-` behind HTTPS: the browser then refuses the cookie from any
|
|
150
160
|
// other origin, path or domain, so nobody can fix a session into this
|
|
@@ -191,7 +201,8 @@ export function createOidcInteractionRoutes(options) {
|
|
|
191
201
|
const clientId = String(params.client_id);
|
|
192
202
|
const client = await provider.Client.find(clientId);
|
|
193
203
|
const clientName = client?.metadata()?.client_name;
|
|
194
|
-
|
|
204
|
+
const resources = canonicalizeRequestedResources(params.resource, options);
|
|
205
|
+
store.saveApproval(clientId, String(params.redirect_uri ?? ''), typeof clientName === 'string' ? clientName : undefined, resources);
|
|
195
206
|
console.log(`mcp-hub: approved OAuth client ${logSafe(clientId)} for ${logSafe(String(params.redirect_uri ?? ''))}`);
|
|
196
207
|
// The grant itself is minted by loadExistingGrant on the resumed request,
|
|
197
208
|
// which is the same code path an already-approved client takes.
|
package/dist/auth/oidc/mount.js
CHANGED
|
@@ -76,7 +76,12 @@ export function defaultRateLimits() {
|
|
|
76
76
|
'/register/:id': [earlyRateLimit(60 * 60_000, 60, 600)],
|
|
77
77
|
'/authorize': [earlyRateLimit(15 * 60_000, 100, 1_000)],
|
|
78
78
|
'/authorize/:uid': [earlyRateLimit(15 * 60_000, 100, 1_000)],
|
|
79
|
-
'/token': [earlyRateLimit(15 * 60_000, 50, 500)]
|
|
79
|
+
'/token': [earlyRateLimit(15 * 60_000, 50, 500)],
|
|
80
|
+
// '/revoke' authenticates the client exactly as '/token' does,
|
|
81
|
+
// private_key_jwt signature checks included, for any caller that has
|
|
82
|
+
// registered itself — so it gets the same budget, counted separately so a
|
|
83
|
+
// flood of one cannot starve the other.
|
|
84
|
+
'/revoke': [earlyRateLimit(15 * 60_000, 50, 500)]
|
|
80
85
|
};
|
|
81
86
|
}
|
|
82
87
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Provider, { errors } from 'oidc-provider';
|
|
2
2
|
import { isSafeRedirectUri, redirectUriMatches } from '../redirect-uri.js';
|
|
3
|
+
import { approvalResourceKey } from '../resource.js';
|
|
3
4
|
import { clampDisplayName } from '../text.js';
|
|
4
5
|
import { createOidcAdapter } from './adapter.js';
|
|
5
6
|
import { HUB_SCOPE, installDiscoveryFixups, installThrowawaySecret } from './quirks.js';
|
|
@@ -103,6 +104,11 @@ export function buildOidcProvider(store, options) {
|
|
|
103
104
|
ack: 'draft-02',
|
|
104
105
|
allowFetch: () => false
|
|
105
106
|
},
|
|
107
|
+
// On by default in oidc-provider. The resource server never checks a
|
|
108
|
+
// proof, so a `token_type: "DPoP"` token would promise a sender
|
|
109
|
+
// constraint nothing enforces. Off, clients get the bearer token they
|
|
110
|
+
// actually hold.
|
|
111
|
+
dPoP: { enabled: false },
|
|
106
112
|
resourceIndicators: {
|
|
107
113
|
enabled: true,
|
|
108
114
|
useGrantedResource: () => true,
|
|
@@ -359,9 +365,24 @@ export function buildOidcProvider(store, options) {
|
|
|
359
365
|
.filter(Boolean);
|
|
360
366
|
if (scopes.length > 0)
|
|
361
367
|
grant.addOIDCScope(scopes.join(' '));
|
|
368
|
+
/**
|
|
369
|
+
* The resource gets the same discipline as the redirect URI: the page
|
|
370
|
+
* named it under "Requested access", so the approval covers that
|
|
371
|
+
* resource and no other. One the operator has not approved is left off
|
|
372
|
+
* the grant, and oidc-provider's own `rs_scopes_missing` check then asks
|
|
373
|
+
* — the consent page with a live session, the login page without — and
|
|
374
|
+
* approving adds it before this runs again for the resumed request.
|
|
375
|
+
*
|
|
376
|
+
* Matched in raw and canonical form, since the interaction routes may
|
|
377
|
+
* run without a canonicaliser; added in raw form, because that is the
|
|
378
|
+
* key `rs_scopes_missing` looks the resource up by.
|
|
379
|
+
*/
|
|
380
|
+
const approvedResources = approval?.resources ?? [];
|
|
362
381
|
const requested = ctx.oidc.params?.resource;
|
|
363
|
-
for (const resource of [requested ?? []].flat()) {
|
|
364
|
-
|
|
382
|
+
for (const resource of [requested ?? []].flat().map(String)) {
|
|
383
|
+
const key = approvalResourceKey(resource, options.externalUrl, options.resolveResource);
|
|
384
|
+
if (approvedResources.includes(resource) || approvedResources.includes(key))
|
|
385
|
+
grant.addResourceScope(resource, HUB_SCOPE);
|
|
365
386
|
}
|
|
366
387
|
await grant.save();
|
|
367
388
|
return grant;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
|
2
|
-
import { jwtVerify } from 'jose';
|
|
2
|
+
import { decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
|
3
3
|
import { API_TOKEN_SUBJECT } from '../api-tokens.js';
|
|
4
4
|
/**
|
|
5
5
|
* The one error shape `requireBearerAuth` recognises.
|
|
@@ -31,9 +31,11 @@ function invalidToken(message) {
|
|
|
31
31
|
* which is what `tokens revoke` deletes.
|
|
32
32
|
*
|
|
33
33
|
* Order matters. Opaque is tried first because it is the common case and needs
|
|
34
|
-
* no cryptography; a value that is not a stored token then
|
|
35
|
-
* signature check
|
|
36
|
-
*
|
|
34
|
+
* no cryptography; a value that is not a stored token then reaches at most one
|
|
35
|
+
* signature check, and only if its decoded claims name a live API token.
|
|
36
|
+
* Neither branch may report why it failed — an
|
|
37
|
+
* attacker must not be able to tell "unknown" from "revoked" from "wrong
|
|
38
|
+
* audience".
|
|
37
39
|
*/
|
|
38
40
|
export class OidcTokenVerifier {
|
|
39
41
|
store;
|
|
@@ -76,6 +78,12 @@ export class OidcTokenVerifier {
|
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
async fromApiToken(token) {
|
|
81
|
+
// Anyone can send a well-formed, wrongly signed JWT for free, and an EdDSA
|
|
82
|
+
// verification costs about twenty times a decode. The decode proves
|
|
83
|
+
// nothing, so it can only reject early: a token naming a live jti still
|
|
84
|
+
// has its signature checked below before anything is trusted.
|
|
85
|
+
if (!this.mayBeLiveApiToken(token))
|
|
86
|
+
throw invalidToken('Invalid or expired access token');
|
|
79
87
|
let payload;
|
|
80
88
|
try {
|
|
81
89
|
({ payload } = await jwtVerify(token, this.store.publicKey, {
|
|
@@ -92,6 +100,9 @@ export class OidcTokenVerifier {
|
|
|
92
100
|
// credential nothing can revoke.
|
|
93
101
|
if (payload.sub !== API_TOKEN_SUBJECT)
|
|
94
102
|
throw invalidToken('Invalid access token claims');
|
|
103
|
+
// Checked again after verification, not only in the pre-check: a
|
|
104
|
+
// `tokens revoke` that lands while the signature is being verified must
|
|
105
|
+
// still refuse this request.
|
|
95
106
|
if (typeof payload.jti !== 'string' || !this.store.getApiToken(payload.jti)) {
|
|
96
107
|
throw invalidToken('Access token has been revoked');
|
|
97
108
|
}
|
|
@@ -107,6 +118,26 @@ export class OidcTokenVerifier {
|
|
|
107
118
|
resource
|
|
108
119
|
};
|
|
109
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Whether a token's unverified claims could belong to a live API token. The
|
|
123
|
+
* checks mirror what `fromApiToken` requires after verification —
|
|
124
|
+
* algorithm, subject, a live jti — so this never rejects a token that would
|
|
125
|
+
* otherwise pass.
|
|
126
|
+
*/
|
|
127
|
+
mayBeLiveApiToken(token) {
|
|
128
|
+
let header;
|
|
129
|
+
let payload;
|
|
130
|
+
try {
|
|
131
|
+
header = decodeProtectedHeader(token);
|
|
132
|
+
payload = decodeJwt(token);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (header.alg !== 'EdDSA' || payload.sub !== API_TOKEN_SUBJECT || typeof payload.jti !== 'string')
|
|
138
|
+
return false;
|
|
139
|
+
return this.store.getApiToken(payload.jti) !== undefined;
|
|
140
|
+
}
|
|
110
141
|
resolve(audience) {
|
|
111
142
|
let parsed;
|
|
112
143
|
try {
|
package/dist/auth/page.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeInvisibles } from './text.js';
|
|
1
2
|
export function escapeHtml(value) {
|
|
2
3
|
return value.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
3
4
|
}
|
|
@@ -8,16 +9,21 @@ export function escapeHtml(value) {
|
|
|
8
9
|
* identity that cannot be invented, which is why the specification asks for it
|
|
9
10
|
* to be shown; a client whose redirect URIs are all local cannot be attributed
|
|
10
11
|
* to anything at all, so that gets said outright.
|
|
12
|
+
*
|
|
13
|
+
* Each field also goes through `escapeInvisibles`: registration refuses bidi
|
|
14
|
+
* and zero-width characters now, but a client stored before that must not be
|
|
15
|
+
* able to reorder the line the operator judges it by. (`clientName` is
|
|
16
|
+
* cleaned by `clampDisplayName` before it gets here.)
|
|
11
17
|
*/
|
|
12
18
|
export function renderIdentity(redirectUri, identity) {
|
|
13
19
|
const lines = [
|
|
14
20
|
' <p class="label">Requested access</p>',
|
|
15
|
-
` <code class="target">${escapeHtml(identity.resource ?? 'Every server on this hub')}</code>`
|
|
21
|
+
` <code class="target">${escapeHtml(escapeInvisibles(identity.resource ?? 'Every server on this hub'))}</code>`
|
|
16
22
|
];
|
|
17
23
|
if (identity.clientId) {
|
|
18
|
-
lines.push(' <p class="label">Identified by</p>', ` <code class="target">${escapeHtml(identity.clientId)}</code>`);
|
|
24
|
+
lines.push(' <p class="label">Identified by</p>', ` <code class="target">${escapeHtml(escapeInvisibles(identity.clientId))}</code>`);
|
|
19
25
|
}
|
|
20
|
-
lines.push(' <p class="label">Codes will be sent to</p>', ` <code class="target">${escapeHtml(redirectUri)}</code>`);
|
|
26
|
+
lines.push(' <p class="label">Codes will be sent to</p>', ` <code class="target">${escapeHtml(escapeInvisibles(redirectUri))}</code>`);
|
|
21
27
|
if (identity.loopbackOnly) {
|
|
22
28
|
lines.push(' <p class="error">This client only accepts codes on this machine, so any program running here could be the one asking.</p>');
|
|
23
29
|
}
|
|
@@ -14,6 +14,38 @@ const DANGEROUS_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:',
|
|
|
14
14
|
function isLoopbackHostname(hostname) {
|
|
15
15
|
return LOOPBACK_HOSTNAMES.has(hostname);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Printable ASCII, 0x21 `!` through 0x7E `~`. A URI is an ASCII string by
|
|
19
|
+
* definition (RFC 3986): a control character, a space or any non-ASCII code
|
|
20
|
+
* point — a bidi override or a zero-width character included — belongs in one
|
|
21
|
+
* only as `%XX`, and that encoding is itself inside this range.
|
|
22
|
+
*/
|
|
23
|
+
const PRINTABLE_ASCII_ONLY = /^[\x21-\x7E]+$/;
|
|
24
|
+
export function isPrintableAsciiUri(value) {
|
|
25
|
+
return PRINTABLE_ASCII_ONLY.test(value);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether a URI names a user before its host, `https://claude.ai@evil.example`
|
|
29
|
+
* being the reason: the reader's eye lands on the part before the `@`, the
|
|
30
|
+
* browser connects to the part after it.
|
|
31
|
+
*
|
|
32
|
+
* Two checks, because each misses a spelling the other catches. The raw string
|
|
33
|
+
* is needed for an empty userinfo, which the WHATWG parser drops without a
|
|
34
|
+
* trace (`https://:@host` parses to an empty username and password). The parsed
|
|
35
|
+
* URL is needed for authorities the parser finds by another spelling, such as
|
|
36
|
+
* a backslash in place of a slash (`https:/\user@host`). An `@` in the path
|
|
37
|
+
* or query, or percent-encoded, is not userinfo and stays allowed.
|
|
38
|
+
*/
|
|
39
|
+
export function hasUserinfo(uri, parsed) {
|
|
40
|
+
if (parsed.username || parsed.password)
|
|
41
|
+
return true;
|
|
42
|
+
const marker = uri.indexOf('://');
|
|
43
|
+
if (marker === -1)
|
|
44
|
+
return false;
|
|
45
|
+
const afterMarker = uri.slice(marker + 3);
|
|
46
|
+
const authorityEnd = afterMarker.search(/[/?#\\]/);
|
|
47
|
+
return (authorityEnd === -1 ? afterMarker : afterMarker.slice(0, authorityEnd)).includes('@');
|
|
48
|
+
}
|
|
17
49
|
/**
|
|
18
50
|
* https anywhere, plain http only on this machine, and — where the policy
|
|
19
51
|
* allows it — a private-use scheme for a native client.
|
|
@@ -21,8 +53,14 @@ function isLoopbackHostname(hostname) {
|
|
|
21
53
|
* The point of refusing remote `http://` is that the code travels in the clear
|
|
22
54
|
* on the final redirect: anyone on the path between the browser and the client
|
|
23
55
|
* reads it, and a public client has nothing else to prove itself with.
|
|
56
|
+
*
|
|
57
|
+
* Every scheme is held to printable ASCII and refused with a userinfo part:
|
|
58
|
+
* this is the address the login and consent pages show as the one thing "not
|
|
59
|
+
* chosen by the application", so it must read as what it is.
|
|
24
60
|
*/
|
|
25
61
|
export function isSafeRedirectUri(uri, policy) {
|
|
62
|
+
if (!PRINTABLE_ASCII_ONLY.test(uri))
|
|
63
|
+
return false;
|
|
26
64
|
let parsed;
|
|
27
65
|
try {
|
|
28
66
|
parsed = new URL(uri);
|
|
@@ -30,6 +68,8 @@ export function isSafeRedirectUri(uri, policy) {
|
|
|
30
68
|
catch {
|
|
31
69
|
return false;
|
|
32
70
|
}
|
|
71
|
+
if (hasUserinfo(uri, parsed))
|
|
72
|
+
return false;
|
|
33
73
|
if (DANGEROUS_SCHEMES.has(parsed.protocol))
|
|
34
74
|
return false;
|
|
35
75
|
if (parsed.protocol === 'https:')
|
package/dist/auth/resource.js
CHANGED
|
@@ -9,6 +9,22 @@ export function canonicalResourceUrl(resource, origin, config) {
|
|
|
9
9
|
return undefined;
|
|
10
10
|
return new URL(`/${match[1]}/mcp`, origin);
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* The form an operator approval records a resource in, and is looked up by:
|
|
14
|
+
* canonical when a canonicaliser is available and the value parses, the raw
|
|
15
|
+
* value otherwise. The issuer itself — the audience of an unbound token —
|
|
16
|
+
* names no MCP route and is kept as it is.
|
|
17
|
+
*/
|
|
18
|
+
export function approvalResourceKey(resource, issuer, canonicalize) {
|
|
19
|
+
if (resource === issuer || !canonicalize)
|
|
20
|
+
return resource;
|
|
21
|
+
try {
|
|
22
|
+
return canonicalize(new URL(resource))?.href ?? resource;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return resource;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
12
28
|
export function resourceUrlForRoute(origin, name) {
|
|
13
29
|
return new URL(name === 'hub' ? '/hub' : `/${name}/mcp`, origin);
|
|
14
30
|
}
|
package/dist/auth/store.js
CHANGED
|
@@ -296,6 +296,17 @@ export class AuthStore {
|
|
|
296
296
|
this.state = next;
|
|
297
297
|
this.signature = this.fileSignature();
|
|
298
298
|
}
|
|
299
|
+
/** An approval as a state file may hold it: a missing or malformed field
|
|
300
|
+
* becomes empty — approved for nothing — rather than stopping the load. */
|
|
301
|
+
static normalizeApproval(approval) {
|
|
302
|
+
const source = (approval && typeof approval === 'object' ? approval : {});
|
|
303
|
+
return {
|
|
304
|
+
redirectUris: Array.isArray(source.redirectUris) ? source.redirectUris.filter((uri) => typeof uri === 'string') : [],
|
|
305
|
+
resources: Array.isArray(source.resources) ? source.resources.filter((r) => typeof r === 'string') : [],
|
|
306
|
+
...(typeof source.clientName === 'string' ? { clientName: source.clientName } : {}),
|
|
307
|
+
approvedAt: typeof source.approvedAt === 'number' ? source.approvedAt : Math.floor(Date.now() / 1000)
|
|
308
|
+
};
|
|
309
|
+
}
|
|
299
310
|
/**
|
|
300
311
|
* Shapes a parsed state file, or undefined when it is unusable. Fields added
|
|
301
312
|
* later default to empty: a state.json written before client approvals
|
|
@@ -310,7 +321,7 @@ export class AuthStore {
|
|
|
310
321
|
cookieSecret: state.cookieSecret,
|
|
311
322
|
clients: bare(state.clients),
|
|
312
323
|
refreshTokens: bare(state.refreshTokens),
|
|
313
|
-
approvals: bare(state.approvals),
|
|
324
|
+
approvals: bare(Object.fromEntries(Object.entries(bare(state.approvals)).map(([id, approval]) => [id, AuthStore.normalizeApproval(approval)]))),
|
|
314
325
|
consumedRefreshTokens: bare(state.consumedRefreshTokens),
|
|
315
326
|
revokedBefore: bare(state.revokedBefore),
|
|
316
327
|
apiTokens: bare(state.apiTokens),
|
|
@@ -740,14 +751,20 @@ export class AuthStore {
|
|
|
740
751
|
this.reloadIfChanged();
|
|
741
752
|
return this.state.approvals[clientId];
|
|
742
753
|
}
|
|
743
|
-
/**
|
|
744
|
-
*
|
|
745
|
-
|
|
754
|
+
/**
|
|
755
|
+
* Records consent for one client. A client may use several redirect URIs
|
|
756
|
+
* and be approved for several resources over time, so both accumulate.
|
|
757
|
+
* `resources` are the ones the page showed; `mcp-hub-admin clients add`
|
|
758
|
+
* has no page and passes none, so it approves the redirect target only.
|
|
759
|
+
*/
|
|
760
|
+
saveApproval(clientId, redirectUri, clientName, resources = []) {
|
|
746
761
|
this.mutate(() => {
|
|
747
762
|
const existing = this.state.approvals[clientId];
|
|
748
763
|
const redirectUris = existing ? [...new Set([...existing.redirectUris, redirectUri])] : [redirectUri];
|
|
764
|
+
const mergedResources = [...new Set([...(existing?.resources ?? []), ...resources])];
|
|
749
765
|
this.state.approvals[clientId] = {
|
|
750
766
|
redirectUris,
|
|
767
|
+
resources: mergedResources,
|
|
751
768
|
clientName: clientName ?? existing?.clientName,
|
|
752
769
|
approvedAt: existing?.approvedAt ?? Math.floor(Date.now() / 1000)
|
|
753
770
|
};
|
|
@@ -772,6 +789,8 @@ export class AuthStore {
|
|
|
772
789
|
revokeClientAccess(clientId) {
|
|
773
790
|
return this.mutate(() => {
|
|
774
791
|
delete this.state.approvals[clientId];
|
|
792
|
+
// Only a state file from before the oidc-provider migration still has
|
|
793
|
+
// entries here; nothing writes this map any more.
|
|
775
794
|
let refreshTokens = 0;
|
|
776
795
|
for (const [hash, record] of Object.entries(this.state.refreshTokens)) {
|
|
777
796
|
if (record.clientId === clientId) {
|
|
@@ -779,6 +798,18 @@ export class AuthStore {
|
|
|
779
798
|
refreshTokens++;
|
|
780
799
|
}
|
|
781
800
|
}
|
|
801
|
+
// The live refresh tokens are oidc-provider artifacts. revokedBefore
|
|
802
|
+
// below is what makes them unusable; this only counts them for the
|
|
803
|
+
// operator reading the command's output.
|
|
804
|
+
const now = Math.floor(Date.now() / 1000);
|
|
805
|
+
for (const record of Object.values(this.state.oidcArtifacts.RefreshToken ?? {})) {
|
|
806
|
+
if (record.consumedAt !== undefined)
|
|
807
|
+
continue;
|
|
808
|
+
if (record.expiresAt !== 0 && record.expiresAt < now)
|
|
809
|
+
continue;
|
|
810
|
+
if (record.payload.clientId === clientId)
|
|
811
|
+
refreshTokens++;
|
|
812
|
+
}
|
|
782
813
|
const revokedBefore = Date.now();
|
|
783
814
|
this.state.revokedBefore[clientId] = revokedBefore;
|
|
784
815
|
return { refreshTokens, revokedBefore };
|
package/dist/auth/text.js
CHANGED
|
@@ -33,6 +33,15 @@ export function logSafe(value, maxLength = MAX_LOGGED_LENGTH) {
|
|
|
33
33
|
});
|
|
34
34
|
return escaped.length > maxLength ? `${escaped.slice(0, maxLength)}…` : escaped;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Control, format and line/paragraph-separator characters rewritten as a
|
|
38
|
+
* literal `\u{XXXX}`, so they print instead of acting: a bidi override no
|
|
39
|
+
* longer reorders the text around it, a zero-width character no longer hides.
|
|
40
|
+
* For text shown on the login and consent pages, before `escapeHtml`.
|
|
41
|
+
*/
|
|
42
|
+
export function escapeInvisibles(value) {
|
|
43
|
+
return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, character => `\\u{${(character.codePointAt(0) ?? 0).toString(16)}}`);
|
|
44
|
+
}
|
|
36
45
|
/**
|
|
37
46
|
* A self-declared client name the login and consent pages can show without the
|
|
38
47
|
* name becoming the page. Escaping alone is not enough: the text is still
|
package/dist/index.js
CHANGED
|
@@ -194,7 +194,10 @@ export async function createHub(options) {
|
|
|
194
194
|
externalUrl,
|
|
195
195
|
password: options.password,
|
|
196
196
|
passwordHash: options.passwordHash,
|
|
197
|
-
cimd
|
|
197
|
+
cimd,
|
|
198
|
+
// Same canonicaliser as buildOidcProvider, so an approval is stored
|
|
199
|
+
// under the key the provider looks up.
|
|
200
|
+
resolveResource: resource => canonicalResourceUrl(resource, origin, watcher.current)
|
|
198
201
|
}));
|
|
199
202
|
// Ahead of the mount, so the hub's stricter RFC 7592 handlers win the
|
|
200
203
|
// /register/:id route over oidc-provider's.
|
package/dist/subscriptions.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { logSafe } from './auth/text.js';
|
|
1
2
|
import { booleanEnv, nonNegativeIntegerEnv, positiveIntegerEnv } from './mcp-limits.js';
|
|
2
3
|
/**
|
|
3
4
|
* Carrying a child's change notifications to the client that asked for them.
|
|
@@ -71,6 +72,15 @@ export const MAX_STREAM_MS = nonNegativeIntegerEnv('MCP_SUBSCRIPTION_MAX_MS', 30
|
|
|
71
72
|
* window would otherwise be a million map entries the hub holds for it.
|
|
72
73
|
*/
|
|
73
74
|
const MAX_PENDING_EVENTS = 1024;
|
|
75
|
+
/**
|
|
76
|
+
* Longest `resources/updated` URI the hub holds or forwards; a longer one is
|
|
77
|
+
* dropped. A URI names what to re-read — nothing legitimate comes near this.
|
|
78
|
+
* `MAX_PENDING_EVENTS` bounds how many one window holds, this how large each
|
|
79
|
+
* may be, whichever transport the child sends it over.
|
|
80
|
+
*/
|
|
81
|
+
export const MAX_RESOURCE_URI_BYTES = positiveIntegerEnv('MCP_SUBSCRIPTION_MAX_URI_BYTES', 8 * 1024);
|
|
82
|
+
/** At most one log line a minute per registry about dropped URIs. */
|
|
83
|
+
const OVERSIZED_URI_WARNING_INTERVAL_MS = 60_000;
|
|
74
84
|
/** True unless an operator said otherwise, globally or for this server. */
|
|
75
85
|
export function subscriptionsAllowed(config) {
|
|
76
86
|
return SUBSCRIPTIONS_ENABLED && config.subscriptions !== 'off';
|
|
@@ -131,6 +141,7 @@ export class SubscriptionRegistry {
|
|
|
131
141
|
nextId = 1;
|
|
132
142
|
timer;
|
|
133
143
|
closed = false;
|
|
144
|
+
lastOversizedUriWarningAt = 0;
|
|
134
145
|
constructor(notifier, options = {}) {
|
|
135
146
|
this.notifier = notifier;
|
|
136
147
|
this.options = options;
|
|
@@ -172,6 +183,10 @@ export class SubscriptionRegistry {
|
|
|
172
183
|
publish(event) {
|
|
173
184
|
if (this.closed)
|
|
174
185
|
return;
|
|
186
|
+
if (event.kind === 'resource_updated' && Buffer.byteLength(event.uri, 'utf8') > MAX_RESOURCE_URI_BYTES) {
|
|
187
|
+
this.warnOversizedUri(event.uri);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
175
190
|
const debounceMs = this.options.debounceMs ?? DEBOUNCE_MS;
|
|
176
191
|
if (debounceMs <= 0) {
|
|
177
192
|
this.deliver(event);
|
|
@@ -236,6 +251,14 @@ export class SubscriptionRegistry {
|
|
|
236
251
|
return;
|
|
237
252
|
}
|
|
238
253
|
}
|
|
254
|
+
/** The URI is the child's own text, so it is escaped and truncated. */
|
|
255
|
+
warnOversizedUri(uri) {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
if (now - this.lastOversizedUriWarningAt < OVERSIZED_URI_WARNING_INTERVAL_MS)
|
|
258
|
+
return;
|
|
259
|
+
this.lastOversizedUriWarningAt = now;
|
|
260
|
+
console.error(`mcp-hub: dropped a resources/updated notification whose uri exceeded ${MAX_RESOURCE_URI_BYTES} bytes: ${logSafe(uri)}`);
|
|
261
|
+
}
|
|
239
262
|
close() {
|
|
240
263
|
this.closed = true;
|
|
241
264
|
clearTimeout(this.timer);
|
package/dist/supervisor.js
CHANGED
|
@@ -186,8 +186,16 @@ export class ManagedServer {
|
|
|
186
186
|
restartTimer;
|
|
187
187
|
pingTimer;
|
|
188
188
|
stopping = false;
|
|
189
|
-
/** Failed restarts since the
|
|
189
|
+
/** Failed restarts since the server was last up or used; a start that
|
|
190
|
+
* reaches 'up' and markUsed() reset it, wake() does not. */
|
|
190
191
|
restartsSinceUse = 0;
|
|
192
|
+
/**
|
|
193
|
+
* When a request last revived a server that had given up. The first request
|
|
194
|
+
* after a give-up retries at once; another one within the current backoff is
|
|
195
|
+
* refused, so a caller repeating it cannot restart the server faster than its
|
|
196
|
+
* own crashes allow. Cleared by a start that reaches 'up'.
|
|
197
|
+
*/
|
|
198
|
+
lastGiveUpRetryAt = 0;
|
|
191
199
|
wakeWaiters = [];
|
|
192
200
|
/**
|
|
193
201
|
* Invalidates callbacks of an abandoned transport: sleep() and stop() tear a
|
|
@@ -250,6 +258,15 @@ export class ManagedServer {
|
|
|
250
258
|
if (this.state === 'unauthorized') {
|
|
251
259
|
return Promise.reject(new Error(`Server "${this.name}" needs an upstream login`));
|
|
252
260
|
}
|
|
261
|
+
// A server that gave up revives on request, but no faster than its backoff.
|
|
262
|
+
// A request inside the window fails fast with the error the give-up
|
|
263
|
+
// reported, rather than queueing behind an attempt that is not coming.
|
|
264
|
+
if (this.state === 'sleeping' && this.restartsSinceUse > 0) {
|
|
265
|
+
if (Date.now() - this.lastGiveUpRetryAt < this.backoffMs) {
|
|
266
|
+
return Promise.reject(new Error(`Server "${this.name}" failed to start: ${this.lastError}`));
|
|
267
|
+
}
|
|
268
|
+
this.lastGiveUpRetryAt = Date.now();
|
|
269
|
+
}
|
|
253
270
|
const timeoutMs = this.options.wakeTimeoutMs ?? WAKE_TIMEOUT_MS;
|
|
254
271
|
const promise = new Promise((resolve, reject) => {
|
|
255
272
|
const waiter = {
|
|
@@ -264,16 +281,20 @@ export class ManagedServer {
|
|
|
264
281
|
waiter.timer.unref();
|
|
265
282
|
this.wakeWaiters.push(waiter);
|
|
266
283
|
});
|
|
267
|
-
if (this.state === 'sleeping') {
|
|
268
|
-
|
|
284
|
+
if (this.state === 'sleeping' && this.restartsSinceUse === 0) {
|
|
285
|
+
// Idle sleep or a cache-hydrated boot: no crash history, wake at once.
|
|
269
286
|
this.backoffMs = this.options.backoffInitialMs ?? BACKOFF_INITIAL_MS;
|
|
270
287
|
void this.start();
|
|
271
288
|
}
|
|
272
|
-
else if (this.state === '
|
|
273
|
-
//
|
|
274
|
-
|
|
289
|
+
else if (this.state === 'sleeping') {
|
|
290
|
+
// Given up earlier: this is the one attempt the window above allows.
|
|
291
|
+
// The backoff and the give-up count carry on from where the crashes
|
|
292
|
+
// left them instead of starting over.
|
|
275
293
|
void this.start();
|
|
276
294
|
}
|
|
295
|
+
// 'down': a restart is already scheduled by the backoff. The caller waits
|
|
296
|
+
// for it; cancelling the timer to start now would let requests set the
|
|
297
|
+
// pace of a crash loop.
|
|
277
298
|
// 'starting': the in-flight start resolves the waiter.
|
|
278
299
|
return promise;
|
|
279
300
|
}
|
|
@@ -393,6 +414,9 @@ export class ManagedServer {
|
|
|
393
414
|
// The start itself opens a full idle window, so a pre-warmed server is not
|
|
394
415
|
// swept away just before the tool call it was warmed for.
|
|
395
416
|
this.lastUsedAt = this.startedAt;
|
|
417
|
+
// Coming up is what clears the crash history — being asked for does not.
|
|
418
|
+
this.restartsSinceUse = 0;
|
|
419
|
+
this.lastGiveUpRetryAt = 0;
|
|
396
420
|
// The child's declared identity, on its way into a file LOG_FILE mirrors
|
|
397
421
|
// and fail2ban reads: escaped and bounded like any other stranger's text.
|
|
398
422
|
console.log(`[${this.name}] up (${logSafe(this.serverInfo?.name ?? 'unknown', 100)} ${logSafe(this.serverInfo?.version ?? '', 40)})`.trim());
|
|
@@ -671,6 +695,9 @@ export class ManagedServer {
|
|
|
671
695
|
// forever. Give up until the next wake, which starts fresh.
|
|
672
696
|
console.error(`[${this.name}] down (${logSafe(reason, 500)}), giving up until next use after ${this.restartsSinceUse - 1} failed restarts`);
|
|
673
697
|
this.state = 'sleeping';
|
|
698
|
+
// lastGiveUpRetryAt stays as it is: zero after a first give-up, so the
|
|
699
|
+
// next request retries at once; the time of that retry when the retry
|
|
700
|
+
// is what failed, so the one after it waits for the backoff.
|
|
674
701
|
this.rejectWakeWaiters(new Error(`Server "${this.name}" failed to start: ${reason}`));
|
|
675
702
|
return;
|
|
676
703
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { logSafe } from '../auth/text.js';
|
|
1
3
|
import { buildCreateRequest, containerName } from '../sandbox/container-spec.js';
|
|
2
4
|
import { StreamTransport, setTransportHandlers } from './stream.js';
|
|
3
5
|
/** Guards against a corrupt header turning into a multi-gigabyte allocation. */
|
|
@@ -96,6 +98,9 @@ export class DockerTransport {
|
|
|
96
98
|
stream;
|
|
97
99
|
closing = false;
|
|
98
100
|
stderrTail = '';
|
|
101
|
+
// A frame boundary can split a multi-byte UTF-8 character; the decoder
|
|
102
|
+
// carries the partial bytes into the next frame.
|
|
103
|
+
stderrDecoder = new StringDecoder('utf8');
|
|
99
104
|
constructor(server, config, client, writeStderr = line => process.stderr.write(line)) {
|
|
100
105
|
this.server = server;
|
|
101
106
|
this.config = config;
|
|
@@ -162,6 +167,12 @@ export class DockerTransport {
|
|
|
162
167
|
this.closing = true;
|
|
163
168
|
await this.inner?.close();
|
|
164
169
|
this.stream?.destroy();
|
|
170
|
+
// A last line without a newline, or a character cut off by the exit, would
|
|
171
|
+
// otherwise go down with the container — and it is often the reason.
|
|
172
|
+
const rest = this.stderrTail + this.stderrDecoder.end();
|
|
173
|
+
this.stderrTail = '';
|
|
174
|
+
if (rest)
|
|
175
|
+
this.writeStderr(`[${this.server}] ${logSafe(rest, Infinity)}\n`);
|
|
165
176
|
await this.client.removeContainer(containerName(this.server)).catch(() => { });
|
|
166
177
|
}
|
|
167
178
|
async ensureImage() {
|
|
@@ -177,15 +188,20 @@ export class DockerTransport {
|
|
|
177
188
|
* to the process's stderr rather than through console: stdio children use
|
|
178
189
|
* `stderr: 'inherit'` and bypass console too, which is what keeps LOG_FILE
|
|
179
190
|
* (read by fail2ban) free of server chatter.
|
|
191
|
+
*
|
|
192
|
+
* The container is untrusted, so each line is escaped like any other text
|
|
193
|
+
* from a child: an ESC or a bare CR would otherwise reach the terminal of
|
|
194
|
+
* whoever watches `docker logs -f`. No length cap from logSafe — the 64 KiB
|
|
195
|
+
* tail flush below already bounds a line.
|
|
180
196
|
*/
|
|
181
197
|
logStderr(payload) {
|
|
182
|
-
this.stderrTail +=
|
|
198
|
+
this.stderrTail += this.stderrDecoder.write(payload);
|
|
183
199
|
const lines = this.stderrTail.split('\n');
|
|
184
200
|
this.stderrTail = lines.pop() ?? '';
|
|
185
201
|
for (const line of lines)
|
|
186
|
-
this.writeStderr(`[${this.server}] ${line}\n`);
|
|
202
|
+
this.writeStderr(`[${this.server}] ${logSafe(line, Infinity)}\n`);
|
|
187
203
|
if (this.stderrTail.length > 64 * 1024) {
|
|
188
|
-
this.writeStderr(`[${this.server}] ${this.stderrTail}\n`);
|
|
204
|
+
this.writeStderr(`[${this.server}] ${logSafe(this.stderrTail, Infinity)}\n`);
|
|
189
205
|
this.stderrTail = '';
|
|
190
206
|
}
|
|
191
207
|
}
|
package/dist/upstream/auth.js
CHANGED
|
@@ -4,7 +4,7 @@ import net from 'node:net';
|
|
|
4
4
|
import { isPrivateAddress, resolvePublicAddress } from '../auth/address.js';
|
|
5
5
|
import { boundedResponse, guardedRequest } from '../auth/pinned-fetch.js';
|
|
6
6
|
import { logSafe } from '../auth/text.js';
|
|
7
|
-
import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } from './provider.js';
|
|
7
|
+
import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata, wellFormedOrUndefined } from './provider.js';
|
|
8
8
|
import { boundedRedirectFetch } from './redirects.js';
|
|
9
9
|
/**
|
|
10
10
|
* Everything the hub needs to authenticate itself to one upstream MCP server.
|
|
@@ -70,10 +70,18 @@ export class UpstreamAuth {
|
|
|
70
70
|
get record() {
|
|
71
71
|
return this.store.getUpstreamCredentials(this.identity.serverName, this.fingerprint);
|
|
72
72
|
}
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
73
|
+
/**
|
|
74
|
+
* The full pair including the refresh token: this class is the only thing
|
|
75
|
+
* allowed to spend it, which is why the provider withholds it.
|
|
76
|
+
*
|
|
77
|
+
* A stored record may predate `saveTokens()`'s validation, or have been
|
|
78
|
+
* written by another process — `wellFormedOrUndefined` treats a malformed
|
|
79
|
+
* one as though nothing were stored, so it can never reach `send()`'s
|
|
80
|
+
* `headers.set()` below and throw with the token embedded in its own
|
|
81
|
+
* message.
|
|
82
|
+
*/
|
|
75
83
|
tokens() {
|
|
76
|
-
return this.record?.tokens;
|
|
84
|
+
return wellFormedOrUndefined(this.record?.tokens);
|
|
77
85
|
}
|
|
78
86
|
/** The public key the upstream needs, but only when we sign assertions. */
|
|
79
87
|
async publicJwkIfNeeded() {
|
|
@@ -229,7 +237,15 @@ export class UpstreamAuth {
|
|
|
229
237
|
const resource = discovery.resourceMetadata?.resource ? new URL(discovery.resourceMetadata.resource) : undefined;
|
|
230
238
|
if (this.identity.oauth.grant === 'client_credentials') {
|
|
231
239
|
const tokens = await this.fetchClientCredentialsTokens(discovery, clientInformation, resource);
|
|
232
|
-
|
|
240
|
+
try {
|
|
241
|
+
this.provider().saveTokens(tokens);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// Same failure class as a refused refresh below: a human has to act,
|
|
245
|
+
// and restarting on a timer would only ask the same broken
|
|
246
|
+
// authorization server the same question forever.
|
|
247
|
+
throw new UpstreamLoginRequiredError(this.identity.serverName, error.message);
|
|
248
|
+
}
|
|
233
249
|
return;
|
|
234
250
|
}
|
|
235
251
|
const refreshToken = this.tokens()?.refresh_token;
|
|
@@ -78,6 +78,57 @@ export function hubClientMetadata(identity, publicJwk) {
|
|
|
78
78
|
}
|
|
79
79
|
/** RFC 7523 §2.2: assertions are single-use and short-lived. */
|
|
80
80
|
const ASSERTION_LIFETIME_S = 300;
|
|
81
|
+
/**
|
|
82
|
+
* The shape every token from an upstream has to fit before the hub stores it:
|
|
83
|
+
* printable ASCII without whitespace, at most 16 KiB.
|
|
84
|
+
*
|
|
85
|
+
* RFC 6749 leaves a token's format to the authorization server, but every real
|
|
86
|
+
* one issues an opaque printable string. The hub sends it back as a header
|
|
87
|
+
* value, and `Headers.set()` throws on CR or LF, quoting the value in its
|
|
88
|
+
* message. Stored anyway, such a token would break every later request to that
|
|
89
|
+
* server rather than this one, and put the value into whatever error reached a
|
|
90
|
+
* caller. Past 16 KiB a token is a payload, not an identifier.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_TOKEN_BYTES = 16 * 1024;
|
|
93
|
+
const TOKEN_PATTERN = /^[\x21-\x7e]+$/;
|
|
94
|
+
function isWellFormedToken(value) {
|
|
95
|
+
return typeof value === 'string' && value.length > 0 && value.length <= MAX_TOKEN_BYTES && TOKEN_PATTERN.test(value);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Refuses a token pair the upstream cannot have meant, before anything reaches
|
|
99
|
+
* the state file. Neither the log line nor the thrown message carries the
|
|
100
|
+
* value: a malformed token can still be somebody's secret.
|
|
101
|
+
*/
|
|
102
|
+
function assertWellFormedTokens(tokens) {
|
|
103
|
+
const fields = [
|
|
104
|
+
['access_token', tokens.access_token],
|
|
105
|
+
['refresh_token', tokens.refresh_token]
|
|
106
|
+
];
|
|
107
|
+
for (const [field, value] of fields) {
|
|
108
|
+
if (field === 'refresh_token' && value === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
if (isWellFormedToken(value))
|
|
111
|
+
continue;
|
|
112
|
+
const length = typeof value === 'string' ? value.length : 0;
|
|
113
|
+
console.error(`mcp-hub: rejected a malformed ${field} from an upstream authorization server (${length} characters)`);
|
|
114
|
+
throw new Error(`upstream returned a malformed ${field}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A stored token pair, or undefined when it does not pass the same check. A
|
|
119
|
+
* record may predate the check or come from another process; treating it as
|
|
120
|
+
* absent means the next request refreshes or asks for a login instead of
|
|
121
|
+
* building a header that throws.
|
|
122
|
+
*/
|
|
123
|
+
export function wellFormedOrUndefined(tokens) {
|
|
124
|
+
if (!tokens)
|
|
125
|
+
return undefined;
|
|
126
|
+
if (!isWellFormedToken(tokens.access_token))
|
|
127
|
+
return undefined;
|
|
128
|
+
if (tokens.refresh_token !== undefined && !isWellFormedToken(tokens.refresh_token))
|
|
129
|
+
return undefined;
|
|
130
|
+
return tokens;
|
|
131
|
+
}
|
|
81
132
|
export class UpstreamAuthProvider {
|
|
82
133
|
identity;
|
|
83
134
|
store;
|
|
@@ -148,7 +199,9 @@ export class UpstreamAuthProvider {
|
|
|
148
199
|
...(typeof information.client_secret === 'string' ? { clientSecret: information.client_secret } : {}),
|
|
149
200
|
// RFC 7592 credentials, when the upstream issued them — what `upstream
|
|
150
201
|
// logout` needs to delete the registration again.
|
|
151
|
-
|
|
202
|
+
// It goes out as a bearer header later, so it has to fit the same shape
|
|
203
|
+
// as any other token; a malformed one is dropped rather than stored.
|
|
204
|
+
...(isWellFormedToken(information.registration_access_token)
|
|
152
205
|
? { registrationAccessToken: information.registration_access_token }
|
|
153
206
|
: {}),
|
|
154
207
|
...(typeof information.registration_client_uri === 'string'
|
|
@@ -165,7 +218,7 @@ export class UpstreamAuthProvider {
|
|
|
165
218
|
* the whole family. Refresh belongs to UpstreamAuth, which serializes it.
|
|
166
219
|
*/
|
|
167
220
|
tokens() {
|
|
168
|
-
const stored = this.record?.tokens;
|
|
221
|
+
const stored = wellFormedOrUndefined(this.record?.tokens);
|
|
169
222
|
if (!stored)
|
|
170
223
|
return undefined;
|
|
171
224
|
const { refresh_token: _withheld, ...rest } = stored;
|
|
@@ -173,9 +226,10 @@ export class UpstreamAuthProvider {
|
|
|
173
226
|
}
|
|
174
227
|
/** The full pair, for the one caller that is allowed to spend it. */
|
|
175
228
|
storedTokens() {
|
|
176
|
-
return this.record?.tokens;
|
|
229
|
+
return wellFormedOrUndefined(this.record?.tokens);
|
|
177
230
|
}
|
|
178
231
|
saveTokens(tokens) {
|
|
232
|
+
assertWellFormedTokens(tokens);
|
|
179
233
|
const expiresIn = typeof tokens.expires_in === 'number' ? tokens.expires_in : undefined;
|
|
180
234
|
this.patch({
|
|
181
235
|
tokens: tokens,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '@modelcontextprotocol/server';
|
|
1
2
|
import { logSafe } from '../auth/text.js';
|
|
2
3
|
/**
|
|
3
4
|
* Redirects on the data plane of a remote upstream, followed only within the
|
|
@@ -22,14 +23,108 @@ import { logSafe } from '../auth/text.js';
|
|
|
22
23
|
export const MAX_REDIRECT_HOPS = 3;
|
|
23
24
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
+
* Ceiling on one message from a remote upstream: the same limit the
|
|
27
|
+
* byte-stream transports apply (`src/transports/stream.ts`), because a remote
|
|
28
|
+
* server is no more trusted than a sandboxed one. It bounds a JSON reply as a
|
|
29
|
+
* whole and an event stream per event, so a long-lived stream is never cut
|
|
30
|
+
* off for its length.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_UPSTREAM_RESPONSE_BYTES = STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
33
|
+
function isEventStream(response) {
|
|
34
|
+
const contentType = response.headers.get('content-type');
|
|
35
|
+
return contentType !== null && contentType.split(';')[0].trim().toLowerCase() === 'text/event-stream';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Passes bytes through until more than `maxBytes` have gone by, then errors
|
|
39
|
+
* the stream, so `.json()` or the SDK's SSE reader fails instead of buffering
|
|
40
|
+
* without end. For an event stream the count restarts at every blank line —
|
|
41
|
+
* the event separator, in any mix of CR, LF and CRLF. A CR at the end of a
|
|
42
|
+
* chunk may be the first half of a CRLF, so `sawCR` carries it into the next.
|
|
43
|
+
*/
|
|
44
|
+
function budgetedStream(maxBytes, sse) {
|
|
45
|
+
let sinceBoundary = 0;
|
|
46
|
+
let sawCR = false;
|
|
47
|
+
let terminatorRun = 0;
|
|
48
|
+
// One line terminator (`\r`, `\n` or `\r\n`) completed. Two in a row with no
|
|
49
|
+
// content byte between them is a blank line — the event boundary.
|
|
50
|
+
const closeTerminator = () => {
|
|
51
|
+
if (++terminatorRun < 2)
|
|
52
|
+
return;
|
|
53
|
+
sinceBoundary = 0;
|
|
54
|
+
terminatorRun = 0;
|
|
55
|
+
};
|
|
56
|
+
return new TransformStream({
|
|
57
|
+
transform(chunk, controller) {
|
|
58
|
+
if (!sse) {
|
|
59
|
+
sinceBoundary += chunk.byteLength;
|
|
60
|
+
if (sinceBoundary > maxBytes) {
|
|
61
|
+
controller.error(new Error(`upstream response exceeds the ${maxBytes} byte limit`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
controller.enqueue(chunk);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (let i = 0; i < chunk.byteLength; i++) {
|
|
68
|
+
sinceBoundary++;
|
|
69
|
+
if (sinceBoundary > maxBytes) {
|
|
70
|
+
controller.error(new Error(`upstream SSE event exceeds the ${maxBytes} byte limit`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const byte = chunk[i];
|
|
74
|
+
if (byte === 0x0d) {
|
|
75
|
+
if (sawCR)
|
|
76
|
+
closeTerminator();
|
|
77
|
+
sawCR = true;
|
|
78
|
+
}
|
|
79
|
+
else if (byte === 0x0a) {
|
|
80
|
+
closeTerminator();
|
|
81
|
+
sawCR = false;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (sawCR)
|
|
85
|
+
closeTerminator();
|
|
86
|
+
sawCR = false;
|
|
87
|
+
terminatorRun = 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
controller.enqueue(chunk);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Puts the final response's body under the budget above. A JSON reply that
|
|
96
|
+
* declares more than the limit in `content-length` is refused before reading;
|
|
97
|
+
* an event stream's length describes the connection, not one event, so it is
|
|
98
|
+
* not consulted there.
|
|
99
|
+
*/
|
|
100
|
+
function capResponseBody(response, maxBytes) {
|
|
101
|
+
if (!response.body)
|
|
102
|
+
return response;
|
|
103
|
+
const sse = isEventStream(response);
|
|
104
|
+
if (!sse) {
|
|
105
|
+
const declared = Number(response.headers.get('content-length'));
|
|
106
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
107
|
+
void response.body.cancel().catch(() => { });
|
|
108
|
+
throw new Error(`upstream declared a ${declared} byte response, exceeding the ${maxBytes} byte limit`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return new Response(response.body.pipeThrough(budgetedStream(maxBytes, sse)), {
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
headers: response.headers
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Wraps a fetch so that every redirect it would follow is checked first and
|
|
119
|
+
* the response it returns is byte-budgeted.
|
|
26
120
|
*
|
|
27
121
|
* `origin` is the configured upstream's origin (`new URL(config.url).origin`).
|
|
28
122
|
* The returned function has the platform's shape and can be handed to the SDK
|
|
29
123
|
* transports as their `fetch`; every hop goes through `fetchImpl`, so a wrapper
|
|
30
|
-
* that adds headers still adds them on each hop.
|
|
124
|
+
* that adds headers still adds them on each hop. `maxBytes` is a parameter so
|
|
125
|
+
* tests can exercise the budget without megabytes of fixtures.
|
|
31
126
|
*/
|
|
32
|
-
export function boundedRedirectFetch(origin, fetchImpl = fetch) {
|
|
127
|
+
export function boundedRedirectFetch(origin, fetchImpl = fetch, maxBytes = MAX_UPSTREAM_RESPONSE_BYTES) {
|
|
33
128
|
return async (input, init) => {
|
|
34
129
|
let url = new URL(input instanceof Request ? input.url : String(input));
|
|
35
130
|
let request = { ...init, redirect: 'manual' };
|
|
@@ -38,10 +133,10 @@ export function boundedRedirectFetch(origin, fetchImpl = fetch) {
|
|
|
38
133
|
// lives there. Nothing in the hub does, but the shape is the platform's.
|
|
39
134
|
const response = await fetchImpl(hop === 0 && input instanceof Request ? new Request(input, request) : url, request);
|
|
40
135
|
if (!REDIRECT_STATUSES.has(response.status))
|
|
41
|
-
return response;
|
|
136
|
+
return capResponseBody(response, maxBytes);
|
|
42
137
|
const location = response.headers.get('location');
|
|
43
138
|
if (location === null)
|
|
44
|
-
return response;
|
|
139
|
+
return capResponseBody(response, maxBytes);
|
|
45
140
|
// Nothing of the redirect's body is wanted, and holding the stream open
|
|
46
141
|
// would keep the connection with it.
|
|
47
142
|
await response.body?.cancel().catch(() => { });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ni-c/mcp-hub",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Serve multiple stdio MCP servers from one container: Claude-Code-style mcpServers config, path-based routing, hub meta-tools, and CIMD-first OAuth 2.1 + API tokens for ChatGPT, Claude and any Streamable-HTTP MCP client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@types/node": "^26.2.0",
|
|
79
79
|
"@types/oidc-provider": "^9.11.1",
|
|
80
80
|
"@types/supertest": "^7.2.1",
|
|
81
|
-
"@vitest/coverage-v8": "5.0.
|
|
81
|
+
"@vitest/coverage-v8": "5.0.1",
|
|
82
82
|
"fast-check": "^4.9.0",
|
|
83
83
|
"oxlint": "^1.80.0",
|
|
84
84
|
"supertest": "^7.0.0",
|