@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/README.md +79 -12
  3. package/dist/admin.js +1 -1
  4. package/dist/auth/api-tokens.js +27 -0
  5. package/dist/auth/headers.js +22 -1
  6. package/dist/auth/oidc/adapter.js +135 -0
  7. package/dist/auth/oidc/interactions.js +187 -0
  8. package/dist/auth/oidc/mount.js +144 -0
  9. package/dist/auth/oidc/provider.js +440 -0
  10. package/dist/auth/oidc/quirks.js +234 -0
  11. package/dist/auth/oidc/verifier.js +121 -0
  12. package/dist/auth/protected-resource.js +41 -0
  13. package/dist/auth/rate-limit.js +115 -2
  14. package/dist/auth/redirect-uri.js +33 -1
  15. package/dist/auth/registration.js +1 -1
  16. package/dist/auth/session.js +43 -0
  17. package/dist/auth/store.js +160 -1
  18. package/dist/config.js +55 -2
  19. package/dist/docker-proxy/policy.js +1 -0
  20. package/dist/docker-proxy/server.js +1 -0
  21. package/dist/elicitation.js +0 -0
  22. package/dist/forward.js +0 -0
  23. package/dist/hub.js +309 -29
  24. package/dist/index.js +103 -21
  25. package/dist/limits.js +13 -1
  26. package/dist/mcp-limits.js +14 -2
  27. package/dist/proxy.js +246 -40
  28. package/dist/stdio.js +71 -6
  29. package/dist/subscriptions.js +236 -0
  30. package/dist/supervisor.js +322 -22
  31. package/dist/timings.js +61 -0
  32. package/dist/tool-filter.js +1 -0
  33. package/dist/transports/stream.js +33 -20
  34. package/dist/upstream/auth.js +1 -1
  35. package/dist/upstream/routes.js +3 -2
  36. package/package.json +20 -6
  37. package/dist/admin.js.map +0 -1
  38. package/dist/auth/address.js.map +0 -1
  39. package/dist/auth/cimd.js.map +0 -1
  40. package/dist/auth/consent-page.js.map +0 -1
  41. package/dist/auth/headers.js.map +0 -1
  42. package/dist/auth/login-page.js.map +0 -1
  43. package/dist/auth/page.js.map +0 -1
  44. package/dist/auth/pinned-fetch.js.map +0 -1
  45. package/dist/auth/private-key-jwt.js +0 -213
  46. package/dist/auth/private-key-jwt.js.map +0 -1
  47. package/dist/auth/provider.js +0 -437
  48. package/dist/auth/provider.js.map +0 -1
  49. package/dist/auth/rate-limit.js.map +0 -1
  50. package/dist/auth/redirect-uri.js.map +0 -1
  51. package/dist/auth/registration.js.map +0 -1
  52. package/dist/auth/resource.js.map +0 -1
  53. package/dist/auth/routes.js +0 -249
  54. package/dist/auth/routes.js.map +0 -1
  55. package/dist/auth/signed-token.js.map +0 -1
  56. package/dist/auth/store.js.map +0 -1
  57. package/dist/auth/text.js.map +0 -1
  58. package/dist/config.js.map +0 -1
  59. package/dist/docker-proxy/index.js.map +0 -1
  60. package/dist/docker-proxy/policy.js.map +0 -1
  61. package/dist/docker-proxy/secrets-watcher.js.map +0 -1
  62. package/dist/docker-proxy/secrets.js.map +0 -1
  63. package/dist/docker-proxy/server.js.map +0 -1
  64. package/dist/health.js.map +0 -1
  65. package/dist/hub.js.map +0 -1
  66. package/dist/index.js.map +0 -1
  67. package/dist/limits.js.map +0 -1
  68. package/dist/logfile.js.map +0 -1
  69. package/dist/main-module.js.map +0 -1
  70. package/dist/mcp-limits.js.map +0 -1
  71. package/dist/mount-check.js.map +0 -1
  72. package/dist/proxy.js.map +0 -1
  73. package/dist/sandbox/container-spec.js.map +0 -1
  74. package/dist/sandbox/docker-client.js.map +0 -1
  75. package/dist/sandbox/policy-protocol.js.map +0 -1
  76. package/dist/stdio.js.map +0 -1
  77. package/dist/supervisor.js.map +0 -1
  78. package/dist/tool-cache.js.map +0 -1
  79. package/dist/tool-filter.js.map +0 -1
  80. package/dist/transports/docker.js.map +0 -1
  81. package/dist/transports/socket.js.map +0 -1
  82. package/dist/transports/stream.js.map +0 -1
  83. package/dist/upstream/auth.js.map +0 -1
  84. package/dist/upstream/login.js.map +0 -1
  85. package/dist/upstream/provider.js.map +0 -1
  86. package/dist/upstream/routes.js.map +0 -1
  87. package/dist/version.js.map +0 -1
@@ -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 = 3_000, parseOptions, validate) {
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
Binary file