@ni-c/mcp-hub 0.11.4 → 0.11.6
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 +44 -0
- package/dist/docker-proxy/server.js +9 -7
- package/dist/hub.js +35 -35
- package/dist/supervisor.js +10 -4
- package/dist/upstream/auth.js +45 -6
- package/dist/upstream/provider.js +32 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@ 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.6] - 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **Upstream credentials stay with the authorization server that issued
|
|
15
|
+
them** (GHSA-6qxp-vccf-f47h, high). An upstream names its authorization
|
|
16
|
+
server on every discovery, which happens at least once an hour, and the hub
|
|
17
|
+
refreshes, redeems codes and requests client_credentials tokens itself. A
|
|
18
|
+
compromised upstream could therefore name a different authorization server
|
|
19
|
+
and receive the refresh token, a registered or configured client secret, or
|
|
20
|
+
the code being redeemed. The credential record now carries the issuer it
|
|
21
|
+
belongs to. A discovery that names another one is refused before anything is
|
|
22
|
+
sent there, and the server shows `unauthorized`, with both servers named in
|
|
23
|
+
the log. A genuine move is accepted after a logout and a new login:
|
|
24
|
+
`mcp-hub-admin upstream logout <server>`. Records from 0.11.5 or earlier are
|
|
25
|
+
bound to the authorization server named by their cached discovery.
|
|
26
|
+
- `@modelcontextprotocol/client`, `core` and `server` 2.3.0 (were 2.1.0), the
|
|
27
|
+
SDK's own fix for the same advisory, which also binds the credentials its
|
|
28
|
+
flows store to their issuer. `@modelcontextprotocol/sdk` 1.32.1 (was 1.30.0),
|
|
29
|
+
pulled in by `@modelcontextprotocol/server-everything` for the tests only.
|
|
30
|
+
|
|
31
|
+
## [0.11.5] - 2026-10-06
|
|
32
|
+
|
|
33
|
+
### Security
|
|
34
|
+
|
|
35
|
+
- `proxy-addr` 2.0.8 (was 2.0.7, GHSA-jqcg-44mw-7w3h, critical). A trust
|
|
36
|
+
subnet written as an IPv4-mapped IPv6 block with a short prefix, such as
|
|
37
|
+
`::ffff:10.0.0.0/8`, matched every IPv4 address. `trustedProxies` is handed
|
|
38
|
+
to Express as `trust proxy`, so with such an entry any client could set its
|
|
39
|
+
own `req.ip` through `X-Forwarded-For` — the key of the per-caller rate limit
|
|
40
|
+
and the address in the consent log. Plain IPv4 notation (`10.0.0.0/8`) was
|
|
41
|
+
never affected.
|
|
42
|
+
- `source-map-js` 1.2.2 (was 1.2.1, GHSA-68fv-2mgg-jv7q). A development
|
|
43
|
+
dependency only; it is not in the package or the image.
|
|
44
|
+
- The documentation site builds with `vue` and `@vue/server-renderer` 3.5.43
|
|
45
|
+
(was 3.5.41, GHSA-g2v6-rqmx-r4w6). The site renders only its own Markdown at
|
|
46
|
+
build time; neither the package nor the image contains Vue.
|
|
47
|
+
- The image overwrites npm's vendored `brace-expansion` with 5.0.11 instead of
|
|
48
|
+
5.0.9 and now also its vendored `undici`, with 6.28.1. npm 12.0.2 pins
|
|
49
|
+
`undici` 6.27.0, and the latest npm, 12.1.0, still ships 6.28.0 and
|
|
50
|
+
`brace-expansion` 5.0.9, which CVE-2026-19534 (`undici`) and CVE-2026-102276
|
|
51
|
+
and CVE-2026-102278 (`brace-expansion`) apply to. Only the npm CLI inside the
|
|
52
|
+
image uses these copies; the hub's own dependencies were not affected.
|
|
53
|
+
|
|
10
54
|
## [0.11.4] - 2026-09-24
|
|
11
55
|
|
|
12
56
|
### Security
|
|
@@ -30,6 +30,11 @@ function refuse(response, status, reason, closeConnection = false) {
|
|
|
30
30
|
});
|
|
31
31
|
response.end(body);
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* An upgrade is answered on the raw socket, and a refused one must not leave
|
|
35
|
+
* the caller holding a half-open connection: write, then destroy.
|
|
36
|
+
*/
|
|
37
|
+
const hangUp = (clientSocket, status, reason) => clientSocket.end(`HTTP/1.1 ${status} ${status === 403 ? 'Forbidden' : 'Bad Gateway'}\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(reason)}\r\n\r\n${reason}`, () => clientSocket.destroy());
|
|
33
38
|
function readBody(request) {
|
|
34
39
|
return new Promise((resolve, reject) => {
|
|
35
40
|
const chunks = [];
|
|
@@ -251,16 +256,13 @@ export function createDockerProxy(options) {
|
|
|
251
256
|
server.on('upgrade', (request, clientSocket, head) => {
|
|
252
257
|
void (async () => {
|
|
253
258
|
const decision = decide(request.method ?? 'GET', request.url ?? '/', undefined);
|
|
254
|
-
// An upgrade is answered on the raw socket, and a refused one must not
|
|
255
|
-
// leave the caller holding a half-open connection: write, then destroy.
|
|
256
|
-
const hangUp = (status, reason) => clientSocket.end(`HTTP/1.1 ${status} ${status === 403 ? 'Forbidden' : 'Bad Gateway'}\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(reason)}\r\n\r\n${reason}`, () => clientSocket.destroy());
|
|
257
259
|
if (!decision.allow || !decision.upgrade) {
|
|
258
|
-
hangUp(decision.allow ? 403 : decision.status, decision.allow ? 'this endpoint does not support upgrades' : decision.reason);
|
|
260
|
+
hangUp(clientSocket, decision.allow ? 403 : decision.status, decision.allow ? 'this endpoint does not support upgrades' : decision.reason);
|
|
259
261
|
return;
|
|
260
262
|
}
|
|
261
263
|
const ownershipError = await verifyContainer(options, decision);
|
|
262
264
|
if (ownershipError) {
|
|
263
|
-
hangUp(ownershipError.status, ownershipError.reason);
|
|
265
|
+
hangUp(clientSocket, ownershipError.status, ownershipError.reason);
|
|
264
266
|
return;
|
|
265
267
|
}
|
|
266
268
|
const upstream = http.request({
|
|
@@ -297,11 +299,11 @@ export function createDockerProxy(options) {
|
|
|
297
299
|
});
|
|
298
300
|
upstream.on('response', upstreamResponse => {
|
|
299
301
|
upstreamResponse.resume();
|
|
300
|
-
hangUp(502, `docker daemon answered ${upstreamResponse.statusCode} instead of upgrading`);
|
|
302
|
+
hangUp(clientSocket, 502, `docker daemon answered ${upstreamResponse.statusCode} instead of upgrading`);
|
|
301
303
|
});
|
|
302
304
|
upstream.on('error', error => {
|
|
303
305
|
console.error(`docker-proxy: upstream error on attach: ${error.message}`);
|
|
304
|
-
hangUp(502, `docker daemon unreachable: ${error.message}`);
|
|
306
|
+
hangUp(clientSocket, 502, `docker daemon unreachable: ${error.message}`);
|
|
305
307
|
});
|
|
306
308
|
upstream.end();
|
|
307
309
|
})().catch(error => {
|
package/dist/hub.js
CHANGED
|
@@ -178,6 +178,41 @@ const requireAllowedTool = (managed, tool, server) => {
|
|
|
178
178
|
console.warn(`[${managed.name}] refused "${loggableToolName(tool)}": not permitted by allowTools/denyTools`);
|
|
179
179
|
return toolError(`Unknown tool "${tool}" on server "${server}". Use list_tools to see available tools.`);
|
|
180
180
|
};
|
|
181
|
+
/**
|
|
182
|
+
* `hub: false` hides a server's TOOLS from the aggregate — those are meant
|
|
183
|
+
* to be used through the server's own endpoint. Its lifecycle is a different
|
|
184
|
+
* matter: wake_server/sleep_server manage hidden servers too, so the error
|
|
185
|
+
* points at the right door instead of pretending the server does not exist
|
|
186
|
+
* (/health names every server to the same token anyway).
|
|
187
|
+
*/
|
|
188
|
+
const requireExposed = (managed) => {
|
|
189
|
+
if (managed.config.hub)
|
|
190
|
+
return undefined;
|
|
191
|
+
return toolError(`Server "${managed.name}" is not exposed through /hub — connect to its own endpoint /${managed.name}/mcp instead.`);
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Asking about an on-demand server's tools is the strongest hint that a
|
|
195
|
+
* call follows, so a sleeping server is pre-warmed in the background while
|
|
196
|
+
* the cached snapshot answers. Only a server with nothing cached yet (first
|
|
197
|
+
* ever run) blocks on the start — there is nothing truthful to answer from.
|
|
198
|
+
* Always-running servers pass through untouched.
|
|
199
|
+
*/
|
|
200
|
+
const prepare = async (managed) => {
|
|
201
|
+
if (!managed.onDemand)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (!managed.hasSnapshot) {
|
|
204
|
+
try {
|
|
205
|
+
await managed.wake();
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return toolError(`Server "${managed.name}" failed to start: ${reason(error)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (managed.state === 'sleeping') {
|
|
212
|
+
void managed.wake().catch(() => { });
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
};
|
|
181
216
|
/**
|
|
182
217
|
* The aggregate, built for the era it will serve.
|
|
183
218
|
*
|
|
@@ -190,41 +225,6 @@ const requireAllowedTool = (managed, tool, server) => {
|
|
|
190
225
|
export function buildHubServer(supervisor, secret, era) {
|
|
191
226
|
const hub = new McpServer({ name: 'mcp-hub', version: VERSION });
|
|
192
227
|
const findServer = (name) => supervisor.get(name);
|
|
193
|
-
/**
|
|
194
|
-
* `hub: false` hides a server's TOOLS from the aggregate — those are meant
|
|
195
|
-
* to be used through the server's own endpoint. Its lifecycle is a different
|
|
196
|
-
* matter: wake_server/sleep_server manage hidden servers too, so the error
|
|
197
|
-
* points at the right door instead of pretending the server does not exist
|
|
198
|
-
* (/health names every server to the same token anyway).
|
|
199
|
-
*/
|
|
200
|
-
const requireExposed = (managed) => {
|
|
201
|
-
if (managed.config.hub)
|
|
202
|
-
return undefined;
|
|
203
|
-
return toolError(`Server "${managed.name}" is not exposed through /hub — connect to its own endpoint /${managed.name}/mcp instead.`);
|
|
204
|
-
};
|
|
205
|
-
/**
|
|
206
|
-
* Asking about an on-demand server's tools is the strongest hint that a
|
|
207
|
-
* call follows, so a sleeping server is pre-warmed in the background while
|
|
208
|
-
* the cached snapshot answers. Only a server with nothing cached yet (first
|
|
209
|
-
* ever run) blocks on the start — there is nothing truthful to answer from.
|
|
210
|
-
* Always-running servers pass through untouched.
|
|
211
|
-
*/
|
|
212
|
-
const prepare = async (managed) => {
|
|
213
|
-
if (!managed.onDemand)
|
|
214
|
-
return undefined;
|
|
215
|
-
if (!managed.hasSnapshot) {
|
|
216
|
-
try {
|
|
217
|
-
await managed.wake();
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
return toolError(`Server "${managed.name}" failed to start: ${reason(error)}`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
else if (managed.state === 'sleeping') {
|
|
224
|
-
void managed.wake().catch(() => { });
|
|
225
|
-
}
|
|
226
|
-
return undefined;
|
|
227
|
-
};
|
|
228
228
|
hub.registerTool('list_servers', {
|
|
229
229
|
title: 'List MCP servers',
|
|
230
230
|
description: 'List all MCP servers available through this hub, with their status. Call this first to see what is available. ' +
|
package/dist/supervisor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/client/stdio';
|
|
2
|
-
import { Client, StreamableHTTPClientTransport, SSEClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
|
2
|
+
import { AuthorizationServerMismatchError, Client, StreamableHTTPClientTransport, SSEClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
|
3
3
|
import { ListToolsResultSchema } from '@modelcontextprotocol/core';
|
|
4
4
|
import { VERSION } from './version.js';
|
|
5
5
|
import { MAX_TOOL_LIST_PAGES, MAX_TOOLS, MAX_TOOL_METADATA_BYTES, jsonSize } from './mcp-limits.js';
|
|
@@ -17,13 +17,19 @@ import { logSafe } from './auth/text.js';
|
|
|
17
17
|
/**
|
|
18
18
|
* Whether a failure is one a restart could fix.
|
|
19
19
|
*
|
|
20
|
-
* Only
|
|
21
|
-
* usable credential,
|
|
20
|
+
* Only three things mean "a human has to act": our own manager saying there is
|
|
21
|
+
* no usable credential, the SDK giving up on authorization, and the SDK refusing
|
|
22
|
+
* to present a credential to an authorization server it does not belong to —
|
|
23
|
+
* which a restart would only ask again. Everything else —
|
|
22
24
|
* DNS, a 5xx, a timeout — is transient and must keep its backoff, or an
|
|
23
25
|
* upstream that would have recovered on its own would need manual attention.
|
|
24
26
|
*/
|
|
25
27
|
function classifyAuthFailure(error) {
|
|
26
|
-
return error instanceof UpstreamLoginRequiredError ||
|
|
28
|
+
return error instanceof UpstreamLoginRequiredError ||
|
|
29
|
+
error instanceof UnauthorizedError ||
|
|
30
|
+
error instanceof AuthorizationServerMismatchError
|
|
31
|
+
? 'unauthorized'
|
|
32
|
+
: 'restart';
|
|
27
33
|
}
|
|
28
34
|
/**
|
|
29
35
|
* Whether this connection's protocol revision still has `ping`.
|
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, wellFormedOrUndefined } from './provider.js';
|
|
7
|
+
import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata, sameIssuer, 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.
|
|
@@ -38,6 +38,11 @@ const EXPIRY_SKEW_S = 60;
|
|
|
38
38
|
const DISCOVERY_TTL_S = 3600;
|
|
39
39
|
const AS_MAX_BYTES = 256 * 1024;
|
|
40
40
|
const AS_TIMEOUT_MS = 10_000;
|
|
41
|
+
/** The authorization server a discovery names, identified the way the SDK
|
|
42
|
+
* stamps credentials: the issuer its validated metadata declares. */
|
|
43
|
+
function issuerOf(discovery) {
|
|
44
|
+
return discovery.authorizationServerMetadata?.issuer ?? String(discovery.authorizationServerUrl);
|
|
45
|
+
}
|
|
41
46
|
export class UpstreamAuth {
|
|
42
47
|
config;
|
|
43
48
|
store;
|
|
@@ -132,10 +137,38 @@ export class UpstreamAuth {
|
|
|
132
137
|
if (!force && cached?.authorizationServerUrl && now - cached.fetchedAt < DISCOVERY_TTL_S)
|
|
133
138
|
return cached;
|
|
134
139
|
const info = await discoverOAuthServerInfo(this.identity.serverUrl, { fetchFn: this.boundAsFetch });
|
|
140
|
+
const bound = this.boundIssuer();
|
|
141
|
+
const named = issuerOf(info);
|
|
142
|
+
if (bound !== undefined && !sameIssuer(bound, named)) {
|
|
143
|
+
// Refused before anything is saved, so the cached discovery — and with it
|
|
144
|
+
// the revocation endpoint `upstream logout` uses — still names the
|
|
145
|
+
// server the credentials belong to.
|
|
146
|
+
throw new UpstreamLoginRequiredError(this.identity.serverName, `the upstream now names a different authorization server (${logSafe(named)}) than the one its credentials belong to (${logSafe(bound)}); if that move is intended, run "mcp-hub-admin upstream logout ${logSafe(this.identity.serverName)}" and log in again`);
|
|
147
|
+
}
|
|
135
148
|
const discovery = { ...info, fetchedAt: now };
|
|
136
149
|
this.provider().saveDiscoveryState(discovery);
|
|
137
150
|
return discovery;
|
|
138
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* The authorization server the stored credentials belong to, or undefined
|
|
154
|
+
* while nothing is stored that another one could be handed.
|
|
155
|
+
*
|
|
156
|
+
* The upstream names its authorization server on every discovery, and
|
|
157
|
+
* `refreshAuthorization`, `exchangeAuthorization` and the client_credentials
|
|
158
|
+
* request below go wherever it points — the SDK's own SEP-2352 check covers
|
|
159
|
+
* none of these direct calls (GHSA-6qxp-vccf-f47h). A record written before
|
|
160
|
+
* the stamp existed is bound to the authorization server its cached
|
|
161
|
+
* discovery names: the one these credentials have been used with all along.
|
|
162
|
+
*/
|
|
163
|
+
boundIssuer() {
|
|
164
|
+
const record = this.record;
|
|
165
|
+
if (!record || (record.tokens === undefined && record.clientId === undefined))
|
|
166
|
+
return undefined;
|
|
167
|
+
if (record.issuer)
|
|
168
|
+
return record.issuer;
|
|
169
|
+
const cached = record.discovery;
|
|
170
|
+
return cached?.authorizationServerUrl ? issuerOf(cached) : undefined;
|
|
171
|
+
}
|
|
139
172
|
/**
|
|
140
173
|
* The client_id this hub uses with this upstream, registering dynamically the
|
|
141
174
|
* first time when that is the configured mode.
|
|
@@ -154,11 +187,11 @@ export class UpstreamAuth {
|
|
|
154
187
|
throw new UpstreamLoginRequiredError(this.identity.serverName, 'the upstream does not accept client ID metadata documents; use mode "dcr" or "static"');
|
|
155
188
|
}
|
|
156
189
|
const clientId = this.provider().clientMetadataUrl;
|
|
157
|
-
this.provider().saveClientInformation({ client_id: clientId });
|
|
190
|
+
this.provider().saveClientInformation({ client_id: clientId }, { issuer: issuerOf(discovery) });
|
|
158
191
|
return { client_id: clientId };
|
|
159
192
|
}
|
|
160
193
|
const registered = await this.registerDynamically(discovery);
|
|
161
|
-
this.provider().saveClientInformation(registered);
|
|
194
|
+
this.provider().saveClientInformation(registered, { issuer: issuerOf(discovery) });
|
|
162
195
|
console.log(`mcp-hub: registered with ${logSafe(discovery.authorizationServerUrl)} for upstream "${logSafe(this.identity.serverName)}"`);
|
|
163
196
|
return registered;
|
|
164
197
|
}
|
|
@@ -238,7 +271,7 @@ export class UpstreamAuth {
|
|
|
238
271
|
if (this.identity.oauth.grant === 'client_credentials') {
|
|
239
272
|
const tokens = await this.fetchClientCredentialsTokens(discovery, clientInformation, resource);
|
|
240
273
|
try {
|
|
241
|
-
this.provider().saveTokens(tokens);
|
|
274
|
+
this.provider().saveTokens(tokens, { issuer: issuerOf(discovery) });
|
|
242
275
|
}
|
|
243
276
|
catch (error) {
|
|
244
277
|
// Same failure class as a refused refresh below: a human has to act,
|
|
@@ -261,7 +294,7 @@ export class UpstreamAuth {
|
|
|
261
294
|
addClientAuthentication: this.provider().addClientAuthentication,
|
|
262
295
|
fetchFn: this.boundAsFetch
|
|
263
296
|
});
|
|
264
|
-
this.provider().saveTokens(tokens);
|
|
297
|
+
this.provider().saveTokens(tokens, { issuer: issuerOf(discovery) });
|
|
265
298
|
}
|
|
266
299
|
catch (error) {
|
|
267
300
|
// A refresh token the upstream will not honour cannot be recovered from
|
|
@@ -329,6 +362,12 @@ export class UpstreamAuth {
|
|
|
329
362
|
/** Redeems the code the upstream sent back. */
|
|
330
363
|
async finishLogin(login, code) {
|
|
331
364
|
const discovery = await this.discover();
|
|
365
|
+
// The code and its verifier belong to the server the login started at; a
|
|
366
|
+
// discovery that has moved since must not receive them, or the client
|
|
367
|
+
// secret that goes with them.
|
|
368
|
+
if (!sameIssuer(login.authorizationServerUrl, String(discovery.authorizationServerUrl))) {
|
|
369
|
+
throw new UpstreamLoginRequiredError(this.identity.serverName, 'the upstream named a different authorization server while the login was open; start the login again');
|
|
370
|
+
}
|
|
332
371
|
const clientInformation = await this.clientInformation(discovery);
|
|
333
372
|
const tokens = await exchangeAuthorization(login.authorizationServerUrl, {
|
|
334
373
|
metadata: discovery.authorizationServerMetadata,
|
|
@@ -340,7 +379,7 @@ export class UpstreamAuth {
|
|
|
340
379
|
addClientAuthentication: this.provider().addClientAuthentication,
|
|
341
380
|
fetchFn: this.boundAsFetch
|
|
342
381
|
});
|
|
343
|
-
this.provider().saveTokens(tokens);
|
|
382
|
+
this.provider().saveTokens(tokens, { issuer: issuerOf(discovery) });
|
|
344
383
|
}
|
|
345
384
|
/**
|
|
346
385
|
* Best effort: tell the upstream to forget the token (RFC 7009) and, for a
|
|
@@ -129,6 +129,19 @@ export function wellFormedOrUndefined(tokens) {
|
|
|
129
129
|
return undefined;
|
|
130
130
|
return tokens;
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether two issuer identifiers name the same authorization server — the
|
|
134
|
+
* SDK's comparison, with RFC 8414 §3.3's one tolerance: a single trailing `/`.
|
|
135
|
+
*/
|
|
136
|
+
export function sameIssuer(a, b) {
|
|
137
|
+
return a === b || (a.endsWith('/') && a.slice(0, -1) === b) || (b.endsWith('/') && b.slice(0, -1) === a);
|
|
138
|
+
}
|
|
139
|
+
/** The issuer a credential is saved under: the SDK's stamp on the value, or
|
|
140
|
+
* the authorization server the caller resolved. */
|
|
141
|
+
function stampFrom(value, ctx) {
|
|
142
|
+
const issuer = value.issuer ?? ctx?.issuer;
|
|
143
|
+
return issuer ? { issuer } : {};
|
|
144
|
+
}
|
|
132
145
|
export class UpstreamAuthProvider {
|
|
133
146
|
identity;
|
|
134
147
|
store;
|
|
@@ -176,13 +189,22 @@ export class UpstreamAuthProvider {
|
|
|
176
189
|
return undefined;
|
|
177
190
|
return clientMetadataUrl(this.identity.externalUrl, this.identity.serverName, this.store.cookieSecret);
|
|
178
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Handed back with every credential, so the SDK's own flows refuse to present
|
|
194
|
+
* one to a different authorization server just as UpstreamAuth does.
|
|
195
|
+
*/
|
|
196
|
+
issuerStamp() {
|
|
197
|
+
const issuer = this.record?.issuer;
|
|
198
|
+
return issuer ? { issuer } : {};
|
|
199
|
+
}
|
|
179
200
|
clientInformation() {
|
|
180
201
|
// Configured credentials win: with them there is nothing to register, and
|
|
181
202
|
// the SDK skips both CIMD and dynamic registration.
|
|
182
203
|
if (this.identity.oauth.mode === 'static') {
|
|
183
204
|
return {
|
|
184
205
|
client_id: this.identity.oauth.clientId,
|
|
185
|
-
...(this.identity.oauth.clientSecret ? { client_secret: this.identity.oauth.clientSecret } : {})
|
|
206
|
+
...(this.identity.oauth.clientSecret ? { client_secret: this.identity.oauth.clientSecret } : {}),
|
|
207
|
+
...this.issuerStamp()
|
|
186
208
|
};
|
|
187
209
|
}
|
|
188
210
|
const record = this.record;
|
|
@@ -190,11 +212,13 @@ export class UpstreamAuthProvider {
|
|
|
190
212
|
return undefined;
|
|
191
213
|
return {
|
|
192
214
|
client_id: record.clientId,
|
|
193
|
-
...(record.clientSecret ? { client_secret: record.clientSecret } : {})
|
|
215
|
+
...(record.clientSecret ? { client_secret: record.clientSecret } : {}),
|
|
216
|
+
...this.issuerStamp()
|
|
194
217
|
};
|
|
195
218
|
}
|
|
196
|
-
saveClientInformation(information) {
|
|
219
|
+
saveClientInformation(information, ctx) {
|
|
197
220
|
this.patch({
|
|
221
|
+
...stampFrom(information, ctx),
|
|
198
222
|
clientId: information.client_id,
|
|
199
223
|
...(typeof information.client_secret === 'string' ? { clientSecret: information.client_secret } : {}),
|
|
200
224
|
// RFC 7592 credentials, when the upstream issued them — what `upstream
|
|
@@ -222,16 +246,19 @@ export class UpstreamAuthProvider {
|
|
|
222
246
|
if (!stored)
|
|
223
247
|
return undefined;
|
|
224
248
|
const { refresh_token: _withheld, ...rest } = stored;
|
|
225
|
-
return rest;
|
|
249
|
+
return { ...rest, ...this.issuerStamp() };
|
|
226
250
|
}
|
|
227
251
|
/** The full pair, for the one caller that is allowed to spend it. */
|
|
228
252
|
storedTokens() {
|
|
229
253
|
return wellFormedOrUndefined(this.record?.tokens);
|
|
230
254
|
}
|
|
231
|
-
saveTokens(
|
|
255
|
+
saveTokens(stamped, ctx) {
|
|
256
|
+
// The stamp lives on the record, once for tokens and registration alike.
|
|
257
|
+
const { issuer: _stamp, ...tokens } = stamped;
|
|
232
258
|
assertWellFormedTokens(tokens);
|
|
233
259
|
const expiresIn = typeof tokens.expires_in === 'number' ? tokens.expires_in : undefined;
|
|
234
260
|
this.patch({
|
|
261
|
+
...stampFrom(stamped, ctx),
|
|
235
262
|
tokens: tokens,
|
|
236
263
|
...(expiresIn !== undefined ? { accessTokenValidUntil: Math.floor(Date.now() / 1000) + expiresIn } : {})
|
|
237
264
|
});
|
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.6",
|
|
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.2",
|
|
82
82
|
"fast-check": "^4.9.0",
|
|
83
83
|
"oxlint": "^1.80.0",
|
|
84
84
|
"supertest": "^7.0.0",
|