@ni-c/mcp-hub 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -0
- package/README.md +25 -12
- package/dist/admin.js +4 -4
- package/dist/auth/address.js +1 -1
- package/dist/auth/oidc/interactions.js +40 -25
- package/dist/auth/oidc/provider.js +12 -0
- package/dist/auth/password.js +50 -0
- package/dist/auth/pinned-fetch.js +30 -0
- package/dist/auth/registration.js +5 -5
- package/dist/auth/session.js +32 -5
- package/dist/auth/signed-token.js +6 -2
- package/dist/auth/store.js +37 -29
- package/dist/child-text.js +39 -0
- package/dist/config.js +1 -5
- package/dist/docker-proxy/index.js +9 -2
- package/dist/docker-proxy/secrets-watcher.js +1 -1
- package/dist/docker-proxy/secrets.js +5 -1
- package/dist/docker-proxy/server.js +14 -0
- package/dist/elicitation.js +0 -0
- package/dist/forward.js +0 -0
- package/dist/hub.js +14 -7
- package/dist/index.js +19 -2
- package/dist/stdio.js +3 -1
- package/dist/subscriptions.js +11 -0
- package/dist/supervisor.js +23 -15
- package/dist/transports/docker.js +11 -9
- package/dist/transports/stream.js +10 -0
- package/dist/upstream/auth.js +15 -8
- package/dist/upstream/provider.js +1 -1
- package/dist/upstream/routes.js +27 -22
- package/package.json +5 -3
package/dist/upstream/auth.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { discoverOAuthServerInfo, exchangeAuthorization, refreshAuthorization, startAuthorization } from '@modelcontextprotocol/client';
|
|
2
|
+
import dns from 'node:dns/promises';
|
|
3
|
+
import net from 'node:net';
|
|
2
4
|
import { isPrivateAddress, resolvePublicAddress } from '../auth/address.js';
|
|
3
|
-
import { guardedRequest } from '../auth/pinned-fetch.js';
|
|
5
|
+
import { boundedResponse, guardedRequest } from '../auth/pinned-fetch.js';
|
|
4
6
|
import { logSafe } from '../auth/text.js';
|
|
5
7
|
import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } from './provider.js';
|
|
6
8
|
/**
|
|
@@ -81,12 +83,13 @@ export class UpstreamAuth {
|
|
|
81
83
|
async privateAllowed() {
|
|
82
84
|
if (this.allowPrivate === undefined) {
|
|
83
85
|
const hostname = new URL(this.identity.serverUrl).hostname.replace(/^\[|\]$/g, '');
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
// isPrivateAddress accepts numeric addresses only and rejects everything
|
|
87
|
+
// else. Passing a DNS name to it used to enable private access for every
|
|
88
|
+
// named upstream. Failed or mixed DNS answers must not relax the policy.
|
|
89
|
+
const addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true });
|
|
90
|
+
if (addresses.length === 0)
|
|
91
|
+
throw new Error('upstream hostname resolved to no addresses');
|
|
92
|
+
this.allowPrivate = addresses.every(entry => isPrivateAddress(entry.address));
|
|
90
93
|
}
|
|
91
94
|
return this.allowPrivate;
|
|
92
95
|
}
|
|
@@ -95,9 +98,13 @@ export class UpstreamAuth {
|
|
|
95
98
|
async asFetch(input, init) {
|
|
96
99
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
97
100
|
const allowPrivate = await this.privateAllowed();
|
|
101
|
+
if (url.username || url.password || (url.protocol !== 'https:' && !(allowPrivate && url.protocol === 'http:'))) {
|
|
102
|
+
throw new Error('upstream OAuth endpoints must use HTTPS (HTTP is allowed only for private upstreams) without URL credentials');
|
|
103
|
+
}
|
|
98
104
|
const pinned = await resolvePublicAddress(url.hostname, allowPrivate);
|
|
99
105
|
if (!pinned) {
|
|
100
|
-
|
|
106
|
+
const response = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(AS_TIMEOUT_MS) });
|
|
107
|
+
return boundedResponse(response, AS_MAX_BYTES);
|
|
101
108
|
}
|
|
102
109
|
return guardedRequest(url, {
|
|
103
110
|
pinnedAddress: pinned,
|
|
@@ -33,7 +33,7 @@ export function credentialFingerprint(identity) {
|
|
|
33
33
|
const { oauth } = identity;
|
|
34
34
|
return crypto
|
|
35
35
|
.createHash('sha256')
|
|
36
|
-
.update(JSON.stringify([identity.serverUrl, oauth.mode, oauth.grant, oauth.clientId ?? null,
|
|
36
|
+
.update(JSON.stringify([identity.serverUrl, oauth.mode, oauth.grant, oauth.clientId ?? null, oauth.scopes.toSorted()]))
|
|
37
37
|
.digest('hex');
|
|
38
38
|
}
|
|
39
39
|
export function callbackUrl(externalUrl) {
|
package/dist/upstream/routes.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|
|
2
2
|
import { readSignedPayload } from '../auth/signed-token.js';
|
|
3
3
|
import { renderPage, escapeHtml } from '../auth/page.js';
|
|
4
4
|
import { logSafe } from '../auth/text.js';
|
|
5
|
+
import { earlyRateLimit } from '../auth/rate-limit.js';
|
|
5
6
|
import { readSessionCookie } from '../auth/session.js';
|
|
6
7
|
import { UPSTREAM_CALLBACK_PATH, UPSTREAM_CLIENT_METADATA_PREFIX, UpstreamAuthProvider, clientDocumentId, clientMetadataUrl, hubClientMetadata } from './provider.js';
|
|
7
8
|
const page = (res, status, title, body) => {
|
|
@@ -10,6 +11,7 @@ const page = (res, status, title, body) => {
|
|
|
10
11
|
export function createUpstreamRoutes(options) {
|
|
11
12
|
const { store, registry, supervisor, watcher, externalUrl } = options;
|
|
12
13
|
const router = Router();
|
|
14
|
+
const secure = new URL(externalUrl).protocol === 'https:';
|
|
13
15
|
/**
|
|
14
16
|
* One document per upstream, addressed by a derived identifier.
|
|
15
17
|
*
|
|
@@ -17,28 +19,29 @@ export function createUpstreamRoutes(options) {
|
|
|
17
19
|
* hot-reloadable, so a server can become `cimd` long after boot, and an
|
|
18
20
|
* Express route cannot be added later.
|
|
19
21
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
}
|
|
22
|
+
// Unauthenticated, and each hit derives one HMAC per configured upstream and
|
|
23
|
+
// may export a public key: cheap, but not free, and nothing legitimate asks
|
|
24
|
+
// for it more than once per login.
|
|
25
|
+
router.get(`/${UPSTREAM_CLIENT_METADATA_PREFIX}/:id.json`, earlyRateLimit(15 * 60_000, 60, 600), async (req, res) => {
|
|
26
|
+
const wanted = String(req.params.id);
|
|
27
|
+
for (const [name, server] of watcher.current) {
|
|
28
|
+
if (server.kind !== 'remote' || server.oauth?.mode !== 'cimd')
|
|
29
|
+
continue;
|
|
30
|
+
if (clientDocumentId(name, store.cookieSecret) !== wanted)
|
|
31
|
+
continue;
|
|
32
|
+
const identity = { serverName: name, serverUrl: server.url, oauth: server.oauth, externalUrl };
|
|
33
|
+
const provider = new UpstreamAuthProvider(identity, store);
|
|
34
|
+
// The document must name itself byte-for-byte or the upstream refuses it.
|
|
35
|
+
res.json({
|
|
36
|
+
client_id: clientMetadataUrl(externalUrl, name, store.cookieSecret),
|
|
37
|
+
...hubClientMetadata(identity, server.oauth.clientAuth === 'private_key_jwt' ? await provider.publicJwk() : undefined)
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.status(404).json({ error: 'not_found', error_description: 'No upstream publishes a document here' });
|
|
39
42
|
});
|
|
40
43
|
router.get(`/${UPSTREAM_CALLBACK_PATH}`, (req, res) => {
|
|
41
|
-
|
|
44
|
+
return handleCallback(req.query, req.headers.cookie, res);
|
|
42
45
|
});
|
|
43
46
|
async function handleCallback(query, cookie, res) {
|
|
44
47
|
const state = typeof query.state === 'string' ? query.state : '';
|
|
@@ -51,7 +54,7 @@ export function createUpstreamRoutes(options) {
|
|
|
51
54
|
// Proving the browser belongs to the operator, not just to whoever ended up
|
|
52
55
|
// holding the redirect. The session cookie rides along because the upstream
|
|
53
56
|
// sends a top-level navigation and the cookie is SameSite=Lax.
|
|
54
|
-
if (readSessionCookie(cookie, store.cookieSecret) === undefined) {
|
|
57
|
+
if (readSessionCookie(cookie, store.cookieSecret, secure) === undefined) {
|
|
55
58
|
page(res, 401, 'Not signed in', 'Sign in to this hub in the same browser, then run the login again.');
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
@@ -64,7 +67,9 @@ export function createUpstreamRoutes(options) {
|
|
|
64
67
|
}
|
|
65
68
|
if (typeof query.error === 'string') {
|
|
66
69
|
console.warn(`mcp-hub: upstream login for ${logSafe(login.serverName)} was declined: ${logSafe(query.error)}`);
|
|
67
|
-
|
|
70
|
+
// An OAuth error code is a short token; anything else is a page written
|
|
71
|
+
// by whoever answered as the upstream, and it does not get to write ours.
|
|
72
|
+
page(res, 400, 'Authorization declined', `The upstream reported "${logSafe(query.error, 80)}". Nothing was changed.`);
|
|
68
73
|
return;
|
|
69
74
|
}
|
|
70
75
|
const code = typeof query.code === 'string' ? query.code : '';
|
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.2",
|
|
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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
53
53
|
"test:e2e:watch": "vitest --config vitest.e2e.config.ts",
|
|
54
54
|
"typecheck:e2e": "tsc -p tsconfig.e2e.json",
|
|
55
|
+
"typecheck:test": "tsc -p tsconfig.test.json",
|
|
55
56
|
"e2e:record": "tsx e2e/tools/record.ts",
|
|
56
57
|
"e2e:curate": "tsx e2e/tools/curate.ts",
|
|
57
58
|
"prepublishOnly": "npm test && npm run build",
|
|
@@ -77,13 +78,14 @@
|
|
|
77
78
|
"@types/node": "^26.2.0",
|
|
78
79
|
"@types/oidc-provider": "^9.11.1",
|
|
79
80
|
"@types/supertest": "^7.2.1",
|
|
80
|
-
"@vitest/coverage-v8": "
|
|
81
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
82
|
+
"fast-check": "^4.9.0",
|
|
81
83
|
"oxlint": "^1.80.0",
|
|
82
84
|
"supertest": "^7.0.0",
|
|
83
85
|
"svg-asset-set": "https://github.com/ni-c/svg-asset-set/releases/download/v0.1.0/svg-asset-set-0.1.0.tgz",
|
|
84
86
|
"tsx": "^4.19.0",
|
|
85
87
|
"typescript": "^7.0.2",
|
|
86
|
-
"vitest": "^
|
|
88
|
+
"vitest": "^5.0.0"
|
|
87
89
|
},
|
|
88
90
|
"mcpName": "io.github.ni-c/mcp-hub"
|
|
89
91
|
}
|