@ni-c/mcp-hub 0.10.0 → 0.11.1
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 +426 -0
- package/README.md +79 -12
- package/dist/admin.js +1 -1
- package/dist/auth/api-tokens.js +27 -0
- package/dist/auth/headers.js +22 -1
- package/dist/auth/oidc/adapter.js +135 -0
- package/dist/auth/oidc/interactions.js +187 -0
- package/dist/auth/oidc/mount.js +144 -0
- package/dist/auth/oidc/provider.js +440 -0
- package/dist/auth/oidc/quirks.js +234 -0
- package/dist/auth/oidc/verifier.js +121 -0
- package/dist/auth/protected-resource.js +41 -0
- package/dist/auth/rate-limit.js +115 -2
- package/dist/auth/redirect-uri.js +33 -1
- package/dist/auth/registration.js +1 -1
- package/dist/auth/session.js +43 -0
- package/dist/auth/store.js +160 -1
- package/dist/config.js +55 -2
- package/dist/docker-proxy/policy.js +1 -0
- package/dist/docker-proxy/server.js +1 -0
- package/dist/elicitation.js +0 -0
- package/dist/forward.js +0 -0
- package/dist/hub.js +309 -29
- package/dist/index.js +103 -21
- package/dist/limits.js +13 -1
- package/dist/mcp-limits.js +14 -2
- package/dist/proxy.js +246 -40
- package/dist/stdio.js +71 -6
- package/dist/subscriptions.js +236 -0
- package/dist/supervisor.js +322 -22
- package/dist/timings.js +61 -0
- package/dist/tool-filter.js +1 -0
- package/dist/transports/stream.js +33 -20
- package/dist/upstream/auth.js +1 -1
- package/dist/upstream/routes.js +3 -2
- package/package.json +20 -6
- package/dist/admin.js.map +0 -1
- package/dist/auth/address.js.map +0 -1
- package/dist/auth/cimd.js.map +0 -1
- package/dist/auth/consent-page.js.map +0 -1
- package/dist/auth/headers.js.map +0 -1
- package/dist/auth/login-page.js.map +0 -1
- package/dist/auth/page.js.map +0 -1
- package/dist/auth/pinned-fetch.js.map +0 -1
- package/dist/auth/private-key-jwt.js +0 -213
- package/dist/auth/private-key-jwt.js.map +0 -1
- package/dist/auth/provider.js +0 -437
- package/dist/auth/provider.js.map +0 -1
- package/dist/auth/rate-limit.js.map +0 -1
- package/dist/auth/redirect-uri.js.map +0 -1
- package/dist/auth/registration.js.map +0 -1
- package/dist/auth/resource.js.map +0 -1
- package/dist/auth/routes.js +0 -249
- package/dist/auth/routes.js.map +0 -1
- package/dist/auth/signed-token.js.map +0 -1
- package/dist/auth/store.js.map +0 -1
- package/dist/auth/text.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/docker-proxy/index.js.map +0 -1
- package/dist/docker-proxy/policy.js.map +0 -1
- package/dist/docker-proxy/secrets-watcher.js.map +0 -1
- package/dist/docker-proxy/secrets.js.map +0 -1
- package/dist/docker-proxy/server.js.map +0 -1
- package/dist/health.js.map +0 -1
- package/dist/hub.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/limits.js.map +0 -1
- package/dist/logfile.js.map +0 -1
- package/dist/main-module.js.map +0 -1
- package/dist/mcp-limits.js.map +0 -1
- package/dist/mount-check.js.map +0 -1
- package/dist/proxy.js.map +0 -1
- package/dist/sandbox/container-spec.js.map +0 -1
- package/dist/sandbox/docker-client.js.map +0 -1
- package/dist/sandbox/policy-protocol.js.map +0 -1
- package/dist/stdio.js.map +0 -1
- package/dist/supervisor.js.map +0 -1
- package/dist/tool-cache.js.map +0 -1
- package/dist/tool-filter.js.map +0 -1
- package/dist/transports/docker.js.map +0 -1
- package/dist/transports/socket.js.map +0 -1
- package/dist/transports/stream.js.map +0 -1
- package/dist/upstream/auth.js.map +0 -1
- package/dist/upstream/login.js.map +0 -1
- package/dist/upstream/provider.js.map +0 -1
- package/dist/upstream/routes.js.map +0 -1
- package/dist/version.js.map +0 -1
package/dist/auth/store.js
CHANGED
|
@@ -19,6 +19,13 @@ const ACTIVITY_GRANULARITY_S = 3600;
|
|
|
19
19
|
* structure that only ever grows.
|
|
20
20
|
*/
|
|
21
21
|
const REVOCATION_MARKER_TTL_MS = 31 * 24 * 3600_000;
|
|
22
|
+
/**
|
|
23
|
+
* Every artifact expires and every write prunes, so this cap is not the primary
|
|
24
|
+
* bound — it is the backstop for the one case pruning cannot help with: tokens
|
|
25
|
+
* minted faster than they age out. Eviction is oldest-first, which for tokens
|
|
26
|
+
* costs a client one refresh rather than its session.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_OIDC_ARTIFACTS_PER_MODEL = 5_000;
|
|
22
29
|
/**
|
|
23
30
|
* Registration is open (anyone can POST /register), so unconfirmed clients
|
|
24
31
|
* would otherwise accumulate on disk without bound and every registration
|
|
@@ -106,7 +113,8 @@ export class AuthStore {
|
|
|
106
113
|
apiTokens: {},
|
|
107
114
|
clientLifecycle: {},
|
|
108
115
|
upstreamCredentials: {},
|
|
109
|
-
upstreamLogins: {}
|
|
116
|
+
upstreamLogins: {},
|
|
117
|
+
oidcArtifacts: {}
|
|
110
118
|
};
|
|
111
119
|
if (restored)
|
|
112
120
|
this.signature = this.fileSignature();
|
|
@@ -301,6 +309,7 @@ export class AuthStore {
|
|
|
301
309
|
clientLifecycle: state.clientLifecycle ?? {},
|
|
302
310
|
upstreamCredentials: state.upstreamCredentials ?? {},
|
|
303
311
|
upstreamLogins: state.upstreamLogins ?? {},
|
|
312
|
+
oidcArtifacts: state.oidcArtifacts ?? {},
|
|
304
313
|
...(typeof state.externalUrl === 'string' ? { externalUrl: state.externalUrl } : {})
|
|
305
314
|
};
|
|
306
315
|
}
|
|
@@ -378,6 +387,21 @@ export class AuthStore {
|
|
|
378
387
|
if (login.expiresAt < now)
|
|
379
388
|
delete this.state.upstreamLogins[state];
|
|
380
389
|
}
|
|
390
|
+
for (const [model, records] of Object.entries(this.state.oidcArtifacts)) {
|
|
391
|
+
for (const [id, record] of Object.entries(records)) {
|
|
392
|
+
if (record.expiresAt !== 0 && record.expiresAt < now)
|
|
393
|
+
delete records[id];
|
|
394
|
+
}
|
|
395
|
+
const ids = Object.keys(records);
|
|
396
|
+
if (ids.length > MAX_OIDC_ARTIFACTS_PER_MODEL) {
|
|
397
|
+
const byAge = ids.sort((a, b) => (records[a].expiresAt || Infinity) - (records[b].expiresAt || Infinity));
|
|
398
|
+
for (const id of byAge.slice(0, ids.length - MAX_OIDC_ARTIFACTS_PER_MODEL))
|
|
399
|
+
delete records[id];
|
|
400
|
+
}
|
|
401
|
+
// An empty model map is a row that never goes away.
|
|
402
|
+
if (Object.keys(records).length === 0)
|
|
403
|
+
delete this.state.oidcArtifacts[model];
|
|
404
|
+
}
|
|
381
405
|
}
|
|
382
406
|
// --- Upstream OAuth ------------------------------------------------------
|
|
383
407
|
/** The issuer the hub is running under, recorded so the admin CLI can build a
|
|
@@ -601,6 +625,20 @@ export class AuthStore {
|
|
|
601
625
|
}
|
|
602
626
|
}
|
|
603
627
|
/** Timing-safe check of an RFC 7592 registration access token. */
|
|
628
|
+
/**
|
|
629
|
+
* Attaches the hash of a registration access token to an existing client.
|
|
630
|
+
*
|
|
631
|
+
* The authorization server mints the token and only ever reveals it in the
|
|
632
|
+
* registration response, so the hash has to be recorded from there rather
|
|
633
|
+
* than at the moment the client record is written.
|
|
634
|
+
*/
|
|
635
|
+
rememberRegistrationToken(clientId, token) {
|
|
636
|
+
this.mutate(() => {
|
|
637
|
+
const entry = this.state.clientLifecycle[clientId];
|
|
638
|
+
if (entry)
|
|
639
|
+
entry.registrationTokenHash = AuthStore.hash(token);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
604
642
|
verifyRegistrationToken(clientId, token) {
|
|
605
643
|
this.reloadIfChanged();
|
|
606
644
|
const expected = this.state.clientLifecycle[clientId]?.registrationTokenHash;
|
|
@@ -811,5 +849,126 @@ export class AuthStore {
|
|
|
811
849
|
return true;
|
|
812
850
|
});
|
|
813
851
|
}
|
|
852
|
+
// --- oidc-provider artifacts ---------------------------------------------
|
|
853
|
+
//
|
|
854
|
+
// The backing store for the ten adapter models. Deliberately dumb: the
|
|
855
|
+
// library owns the payload shapes, so this layer only adds the two things it
|
|
856
|
+
// cannot do itself — the cross-process lock every other writer here obeys,
|
|
857
|
+
// and the revokedBefore cutoff below.
|
|
858
|
+
/**
|
|
859
|
+
* Artifacts are keyed by the HASH of their id, never by the id itself.
|
|
860
|
+
*
|
|
861
|
+
* With opaque access tokens the id IS the bearer token, and a refresh token
|
|
862
|
+
* or a registration access token is no different — storing them verbatim
|
|
863
|
+
* would turn read access to state.json into working credentials. The file was
|
|
864
|
+
* already sensitive (it holds the cookie secret), but it never used to hold
|
|
865
|
+
* anything an attacker could present as-is, and it should not start now.
|
|
866
|
+
*
|
|
867
|
+
* Lookups all go through here, and the two scans (`findByUid`,
|
|
868
|
+
* `revokeByGrantId`) match on payload fields rather than on the key, so
|
|
869
|
+
* nothing needs the plaintext back.
|
|
870
|
+
*/
|
|
871
|
+
static artifactKey(id) {
|
|
872
|
+
return AuthStore.hash(id);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Models whose id is a bearer credential someone presents back.
|
|
876
|
+
*
|
|
877
|
+
* Hashing the key alone would not be enough: the payload carries `jti`, and
|
|
878
|
+
* for an opaque token the jti IS the token. These are stored without it and
|
|
879
|
+
* get it back from the lookup id on the way out, which `find` always has.
|
|
880
|
+
* The others (Session, Interaction, Grant) are referenced by ids that are not
|
|
881
|
+
* credentials, and their jti has to survive a `findByUid` that never sees one.
|
|
882
|
+
*/
|
|
883
|
+
static CREDENTIAL_MODELS = new Set([
|
|
884
|
+
'AccessToken',
|
|
885
|
+
'RefreshToken',
|
|
886
|
+
'AuthorizationCode',
|
|
887
|
+
'RegistrationAccessToken',
|
|
888
|
+
'ClientCredentials',
|
|
889
|
+
'InitialAccessToken',
|
|
890
|
+
'DeviceCode',
|
|
891
|
+
'BackchannelAuthenticationRequest'
|
|
892
|
+
]);
|
|
893
|
+
oidcUpsert(model, id, payload, expiresInSeconds) {
|
|
894
|
+
this.mutate(() => {
|
|
895
|
+
const records = (this.state.oidcArtifacts[model] ??= {});
|
|
896
|
+
const key = AuthStore.artifactKey(id);
|
|
897
|
+
const stored = AuthStore.CREDENTIAL_MODELS.has(model) ? { ...payload, jti: undefined } : payload;
|
|
898
|
+
if (AuthStore.CREDENTIAL_MODELS.has(model))
|
|
899
|
+
delete stored.jti;
|
|
900
|
+
records[key] = {
|
|
901
|
+
payload: stored,
|
|
902
|
+
expiresAt: expiresInSeconds ? Math.floor(Date.now() / 1000) + expiresInSeconds : 0,
|
|
903
|
+
...(records[key]?.consumedAt !== undefined ? { consumedAt: records[key].consumedAt } : {})
|
|
904
|
+
};
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Undefined for unknown, expired *and revoked* artifacts — the library treats
|
|
909
|
+
* every falsy return as "not found", so this one comparison is the whole of
|
|
910
|
+
* `mcp-hub-admin clients revoke`. It is why access tokens are opaque: a JWT
|
|
911
|
+
* is never stored, so it could not be reached here at all.
|
|
912
|
+
*/
|
|
913
|
+
oidcFind(model, id) {
|
|
914
|
+
this.reloadIfChanged();
|
|
915
|
+
const record = this.state.oidcArtifacts[model]?.[AuthStore.artifactKey(id)];
|
|
916
|
+
if (!record)
|
|
917
|
+
return undefined;
|
|
918
|
+
if (record.expiresAt !== 0 && record.expiresAt < Math.floor(Date.now() / 1000))
|
|
919
|
+
return undefined;
|
|
920
|
+
const clientId = record.payload.clientId;
|
|
921
|
+
const issuedAt = record.payload.iat;
|
|
922
|
+
if (typeof clientId === 'string' && typeof issuedAt === 'number') {
|
|
923
|
+
const cutoff = this.state.revokedBefore[clientId];
|
|
924
|
+
if (cutoff !== undefined && issuedAt * 1000 < cutoff)
|
|
925
|
+
return undefined;
|
|
926
|
+
}
|
|
927
|
+
const payload = AuthStore.CREDENTIAL_MODELS.has(model) ? { ...record.payload, jti: id } : record.payload;
|
|
928
|
+
return record.consumedAt === undefined ? payload : { ...payload, consumed: record.consumedAt };
|
|
929
|
+
}
|
|
930
|
+
/** Sessions are looked up by `uid` as well as by id. */
|
|
931
|
+
oidcFindBy(model, field, value) {
|
|
932
|
+
this.reloadIfChanged();
|
|
933
|
+
for (const record of Object.values(this.state.oidcArtifacts[model] ?? {})) {
|
|
934
|
+
if (record.payload[field] !== value)
|
|
935
|
+
continue;
|
|
936
|
+
if (record.expiresAt !== 0 && record.expiresAt < Math.floor(Date.now() / 1000))
|
|
937
|
+
return undefined;
|
|
938
|
+
return record.payload;
|
|
939
|
+
}
|
|
940
|
+
return undefined;
|
|
941
|
+
}
|
|
942
|
+
oidcConsume(model, id) {
|
|
943
|
+
this.mutate(() => {
|
|
944
|
+
const record = this.state.oidcArtifacts[model]?.[AuthStore.artifactKey(id)];
|
|
945
|
+
if (record)
|
|
946
|
+
record.consumedAt = Math.floor(Date.now() / 1000);
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
oidcDestroy(model, id) {
|
|
950
|
+
this.mutate(() => {
|
|
951
|
+
delete this.state.oidcArtifacts[model]?.[AuthStore.artifactKey(id)];
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Scanned rather than indexed. A second index would be a third thing to keep
|
|
956
|
+
* consistent across a crash between two writes, and these maps hold hundreds
|
|
957
|
+
* of rows, not millions.
|
|
958
|
+
*/
|
|
959
|
+
oidcRevokeByGrantId(grantId) {
|
|
960
|
+
return this.mutate(() => {
|
|
961
|
+
let revoked = 0;
|
|
962
|
+
for (const records of Object.values(this.state.oidcArtifacts)) {
|
|
963
|
+
for (const [id, record] of Object.entries(records)) {
|
|
964
|
+
if (record.payload.grantId === grantId) {
|
|
965
|
+
delete records[id];
|
|
966
|
+
revoked++;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return revoked;
|
|
971
|
+
});
|
|
972
|
+
}
|
|
814
973
|
}
|
|
815
974
|
//# sourceMappingURL=store.js.map
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { CONFIG_POLL_INTERVAL_MS } from './timings.js';
|
|
4
5
|
export const OAUTH_MODES = new Set(['static', 'dcr', 'cimd']);
|
|
5
6
|
export const OAUTH_GRANTS = new Set(['authorization_code', 'client_credentials']);
|
|
6
7
|
export const OAUTH_CLIENT_AUTH = new Set(['client_secret_basic', 'client_secret_post', 'private_key_jwt']);
|
|
@@ -17,6 +18,15 @@ const RESERVED_NAMES = new Set([
|
|
|
17
18
|
'livez',
|
|
18
19
|
'revoke',
|
|
19
20
|
'.well-known',
|
|
21
|
+
// Served by the authorization server. `interaction` is where an
|
|
22
|
+
// unauthenticated authorization request is sent to log in, and `jwks` and
|
|
23
|
+
// `session` are endpoints oidc-provider registers whether or not the hub
|
|
24
|
+
// advertises them. A server of one of these names would shadow the auth flow
|
|
25
|
+
// rather than merely be unreachable.
|
|
26
|
+
'jwks',
|
|
27
|
+
'interaction',
|
|
28
|
+
'session',
|
|
29
|
+
'userinfo',
|
|
20
30
|
// The upstream OAuth callback lives under /upstream/…; a server of that name
|
|
21
31
|
// would be reachable at /upstream and is too close for comfort.
|
|
22
32
|
'upstream'
|
|
@@ -204,6 +214,49 @@ function parseToolFilter(name, entry) {
|
|
|
204
214
|
}
|
|
205
215
|
return result;
|
|
206
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Parses `passthrough`, which today governs one thing: whether this server may
|
|
219
|
+
* ask the person at the far end a question.
|
|
220
|
+
*
|
|
221
|
+
* A *partial* for the same reason `parseToolFilter` is one — an entry without
|
|
222
|
+
* the field has to produce exactly the object it produced before.
|
|
223
|
+
*
|
|
224
|
+
* `"off"` exists separately from the global `MCP_ELICITATION` switch because
|
|
225
|
+
* the two answer different questions. The global one is "is this hub doing
|
|
226
|
+
* elicitation at all"; this one is "do I trust *this* upstream to put words in
|
|
227
|
+
* front of my user". A server can be perfectly reliable and still be one whose
|
|
228
|
+
* prompts an operator does not want shown — that is a phishing judgement, not
|
|
229
|
+
* an availability one, and it should not require turning the server off.
|
|
230
|
+
*/
|
|
231
|
+
function parsePassthrough(name, entry) {
|
|
232
|
+
if (entry.passthrough === undefined)
|
|
233
|
+
return {};
|
|
234
|
+
if (entry.passthrough !== 'auto' && entry.passthrough !== 'off') {
|
|
235
|
+
throw new ConfigError(`Server "${name}": "passthrough" must be "auto" or "off"`);
|
|
236
|
+
}
|
|
237
|
+
return { passthrough: entry.passthrough };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Parses `subscriptions`, the sibling of `passthrough`: whether this server may
|
|
241
|
+
* push change notifications to the clients that ask for them.
|
|
242
|
+
*
|
|
243
|
+
* A *partial* for the same reason the two above are — an entry without the
|
|
244
|
+
* field has to produce exactly the object it produced before.
|
|
245
|
+
*
|
|
246
|
+
* Separate from `passthrough` because the two are different judgements about
|
|
247
|
+
* different traffic. `passthrough` is about words shown to a person, and the
|
|
248
|
+
* risk is phishing. This one is about volume and timing on a stream nobody is
|
|
249
|
+
* reading synchronously, and the risk is noise. A server can easily warrant one
|
|
250
|
+
* answer and not the other.
|
|
251
|
+
*/
|
|
252
|
+
function parseSubscriptions(name, entry) {
|
|
253
|
+
if (entry.subscriptions === undefined)
|
|
254
|
+
return {};
|
|
255
|
+
if (entry.subscriptions !== 'auto' && entry.subscriptions !== 'off') {
|
|
256
|
+
throw new ConfigError(`Server "${name}": "subscriptions" must be "auto" or "off"`);
|
|
257
|
+
}
|
|
258
|
+
return { subscriptions: entry.subscriptions };
|
|
259
|
+
}
|
|
207
260
|
function rejectLifecycle(name, entry, kind) {
|
|
208
261
|
for (const field of ['keepAlive', 'idleMinutes']) {
|
|
209
262
|
if (entry[field] !== undefined) {
|
|
@@ -331,7 +384,7 @@ function parseSocketServer(name, entry, type, expand, hub) {
|
|
|
331
384
|
function parseServer(name, entry, env, options) {
|
|
332
385
|
const expand = expanderFor(env, options);
|
|
333
386
|
const hub = entry.hub !== false;
|
|
334
|
-
const toolFilter = parseToolFilter(name, entry);
|
|
387
|
+
const toolFilter = { ...parseToolFilter(name, entry), ...parsePassthrough(name, entry), ...parseSubscriptions(name, entry) };
|
|
335
388
|
if (entry.hub !== undefined && typeof entry.hub !== 'boolean') {
|
|
336
389
|
throw new ConfigError(`Server "${name}": "hub" must be a boolean`);
|
|
337
390
|
}
|
|
@@ -458,7 +511,7 @@ export class ConfigWatcher extends EventEmitter {
|
|
|
458
511
|
validate;
|
|
459
512
|
watcher;
|
|
460
513
|
debounce;
|
|
461
|
-
constructor(filePath, current, env = process.env, pollIntervalMs =
|
|
514
|
+
constructor(filePath, current, env = process.env, pollIntervalMs = CONFIG_POLL_INTERVAL_MS, parseOptions, validate) {
|
|
462
515
|
super();
|
|
463
516
|
this.filePath = filePath;
|
|
464
517
|
this.current = current;
|
|
@@ -2,6 +2,7 @@ import { buildCreateRequest, containerName, serverNameFromContainer, OWNER_LABEL
|
|
|
2
2
|
import { splitImageRef } from '../sandbox/docker-client.js';
|
|
3
3
|
const API_PREFIX = /^\/v\d+\.\d+(?=\/)/;
|
|
4
4
|
/** Control characters would let a caller forge lines in a log fail2ban reads. */
|
|
5
|
+
// eslint-disable-next-line no-control-regex -- matching them is the point
|
|
5
6
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/g;
|
|
6
7
|
const SANDBOX_ID = /^mcp-sandbox-[a-zA-Z0-9_-]+$/;
|
|
7
8
|
const MAX_ENV_ENTRIES = 200;
|
|
@@ -152,6 +152,7 @@ export function createDockerProxy(options) {
|
|
|
152
152
|
// The URL is caller-controlled and this log is read by fail2ban: a
|
|
153
153
|
// percent-encoded newline in a container name would otherwise let the
|
|
154
154
|
// caller write its own log lines.
|
|
155
|
+
// eslint-disable-next-line no-control-regex -- matching them is the point
|
|
155
156
|
const safeUrl = url.replace(/[\u0000-\u001f\u007f]/g, '?').slice(0, 500);
|
|
156
157
|
if (!decision.allow)
|
|
157
158
|
console.warn(`docker-proxy: DENY ${method} ${safeUrl}: ${decision.reason}`);
|
|
Binary file
|
package/dist/forward.js
ADDED
|
Binary file
|