@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
package/dist/hub.js CHANGED
@@ -1,11 +1,118 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer, CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { VERSION } from './version.js';
4
- import { ABSOLUTE_CALL_OPTIONS, assertForwardedResultSize } from './mcp-limits.js';
4
+ import { forwardToolCall } from './forward.js';
5
5
  import { loggableToolName, toolAllowed } from './tool-filter.js';
6
- function text(value) {
7
- return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
6
+ import { booleanEnv } from './mcp-limits.js';
7
+ import { REFUSAL_REASON, decidePassthrough } from './elicitation.js';
8
+ import { REVISION } from './proxy.js';
9
+ /**
10
+ * A meta-tool answer, in both channels at once.
11
+ *
12
+ * `structuredContent` is the machine-readable half and the reason the tools
13
+ * declare an `outputSchema` at all; the text block stays because the SDK does
14
+ * NOT synthesize one for an object-shaped value, and a client that only reads
15
+ * `content` would otherwise get an empty answer. Both carry the same object —
16
+ * the specification's rule is that the two are the same information in two
17
+ * presentations, and the cheapest way to keep that true is to serialise the one
18
+ * value twice rather than to build two.
19
+ */
20
+ function structured(value) {
21
+ return {
22
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
23
+ structuredContent: value
24
+ };
8
25
  }
26
+ /**
27
+ * Every value `ServerState` has, as a schema.
28
+ *
29
+ * Written as a `Record` keyed by the type rather than as a bare list on
30
+ * purpose: a state added to the supervisor and forgotten here would make this
31
+ * schema refuse a perfectly correct answer, and the SDK turns a refused answer
32
+ * into an error result. Keyed this way the omission is a compile error instead.
33
+ */
34
+ const SERVER_STATES = {
35
+ starting: true,
36
+ up: true,
37
+ down: true,
38
+ stopped: true,
39
+ sleeping: true,
40
+ unauthorized: true
41
+ };
42
+ const serverStatus = z
43
+ .enum(Object.keys(SERVER_STATES))
44
+ .describe('What the hub is currently doing with this server.');
45
+ /**
46
+ * A JSON Schema, or a child's annotations: an object whose contents are not
47
+ * ours to describe.
48
+ *
49
+ * `looseObject` and not a stricter shape, because both are somebody else's
50
+ * document. Note that this stays right even under SEP-2106, which lets an
51
+ * `outputSchema` describe a non-object *instance*: the schema itself is still
52
+ * a JSON object — `{"type": "array", …}` — so it is the instance root that got
53
+ * freer, not the document.
54
+ *
55
+ * The `meta` is not decoration. Left to itself zod writes "accepts anything" as
56
+ * `"additionalProperties": {}` — an empty schema, which is legal and means
57
+ * exactly the same as `true`, but is the spelling some MCP clients refuse or
58
+ * mishandle. `meta` is merged into the emitted JSON Schema and nothing else, so
59
+ * the wire says `true` while the runtime stays as permissive as it has to be.
60
+ */
61
+ const foreignDocument = z.looseObject({}).meta({ additionalProperties: true });
62
+ const listServersOutput = z.object({
63
+ servers: z.array(z.object({
64
+ name: z.string().describe('Name to pass to the other tools.'),
65
+ description: z.string().describe("The child's own title, empty if it has never connected."),
66
+ status: serverStatus,
67
+ toolCount: z.number().int().describe('Tools this server offers through the hub, after its filter.'),
68
+ hidden: z
69
+ .literal(true)
70
+ .optional()
71
+ .describe('Present only for a server whose tools are served by its own endpoint alone.')
72
+ }))
73
+ });
74
+ const listToolsOutput = z.object({
75
+ server: z.string(),
76
+ tools: z.array(z.object({
77
+ name: z.string(),
78
+ description: z.string().describe('First line only, capped at 120 characters.'),
79
+ annotations: foreignDocument.optional().describe("The child's own annotations, verbatim; absent if it declared none."),
80
+ hasOutputSchema: z
81
+ .literal(true)
82
+ .optional()
83
+ .describe('Present when the tool declares an output schema, which get_tool_schema returns.')
84
+ }))
85
+ });
86
+ const getToolSchemaOutput = z.object({
87
+ server: z.string(),
88
+ name: z.string(),
89
+ description: z.string().describe('The full description, untruncated.'),
90
+ inputSchema: foreignDocument.describe('JSON Schema for the arguments of call_tool.'),
91
+ outputSchema: foreignDocument
92
+ .optional()
93
+ .describe('JSON Schema the structuredContent of a call conforms to; absent if the tool declares none.'),
94
+ annotations: foreignDocument.optional()
95
+ });
96
+ const describeConnectionOutput = z.object({
97
+ era: z.enum(['modern', 'legacy']).describe('Which protocol era this very call arrived on.'),
98
+ revision: z.string().describe('The revision string that era puts on the wire.'),
99
+ hubVersion: z.string(),
100
+ caller: z.object({
101
+ declaresElicitation: z
102
+ .boolean()
103
+ .describe('Whether this request carried an elicitation capability. Only the 2026 era can carry one at all.')
104
+ }),
105
+ elicitation: z.object({
106
+ wouldForward: z
107
+ .boolean()
108
+ .optional()
109
+ .describe('Whether a question from the named server would reach you. Absent when no server was named and the answer depends on one.'),
110
+ reason: z.string().optional().describe('Why not, or what the answer still depends on. Absent when a question would be carried.'),
111
+ server: z.string().optional().describe('The server this answer was computed for, if one was named.')
112
+ })
113
+ });
114
+ const wakeServerOutput = z.object({ name: z.string(), status: serverStatus, toolCount: z.number().int() });
115
+ const sleepServerOutput = z.object({ name: z.string(), status: serverStatus });
9
116
  function toolError(message) {
10
117
  return { content: [{ type: 'text', text: message }], isError: true };
11
118
  }
@@ -15,6 +122,38 @@ function firstLine(description) {
15
122
  const line = description.split('\n', 1)[0].trim();
16
123
  return line.length > 120 ? `${line.slice(0, 117)}...` : line;
17
124
  }
125
+ /**
126
+ * The four hints the three reading meta-tools carry.
127
+ *
128
+ * Written out rather than left to the defaults, because the defaults are not
129
+ * neutral: the specification gives `destructiveHint` and `openWorldHint` a
130
+ * default of **true**, so a tool that says nothing announces itself as a
131
+ * destructive tool in an open world. `list_servers`, `list_tools` and
132
+ * `get_tool_schema` only read the hub's own snapshot, and they said nothing.
133
+ *
134
+ * `openWorldHint: false` is right even though the *children* reach all sorts of
135
+ * places: these three do not call them. `call_tool` does, and says so.
136
+ */
137
+ const READS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
138
+ /**
139
+ * A lifecycle switch: it changes nothing a person put there, and doing it twice
140
+ * leaves the same world.
141
+ */
142
+ const LIFECYCLE = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
143
+ /**
144
+ * The child's own annotations, passed on verbatim.
145
+ *
146
+ * Verbatim, and not condensed into a kind/asks pair, because a summary would be
147
+ * a statement *by the hub* about the child's claim. The specification is blunt
148
+ * about what these are worth — "clients MUST consider tool annotations to be
149
+ * untrusted unless they come from trusted servers" — and the hub is in no
150
+ * position to vouch for a child it merely forwards to. It says what it was
151
+ * told, and hub-tools.md says whose word it is.
152
+ *
153
+ * Omitted entirely when the child declared none. An empty object here would
154
+ * read as "all four defaults", which is a claim the child did not make.
155
+ */
156
+ const passThrough = (tool) => tool.annotations === undefined ? {} : { annotations: tool.annotations };
18
157
  /**
19
158
  * The /hub aggregate: one connector exposing every hub-enabled server through
20
159
  * six meta-tools, so a client's context holds 6 tool schemas instead of 9×N.
@@ -34,7 +173,16 @@ const requireAllowedTool = (managed, tool, server) => {
34
173
  console.warn(`[${managed.name}] refused "${loggableToolName(tool)}": not permitted by allowTools/denyTools`);
35
174
  return toolError(`Unknown tool "${tool}" on server "${server}". Use list_tools to see available tools.`);
36
175
  };
37
- export function buildHubServer(supervisor) {
176
+ /**
177
+ * The aggregate, built for the era it will serve.
178
+ *
179
+ * `era` is not decoration: both entry points already construct one instance per
180
+ * era — `createMcpHandler` per request, `serveStdio` per connection — and the
181
+ * SDK hands the era to the factory precisely so a server can vary by it. The
182
+ * only thing that varies here is `describe_connection`, which would otherwise
183
+ * have to guess the one fact it exists to report.
184
+ */
185
+ export function buildHubServer(supervisor, secret, era) {
38
186
  const hub = new McpServer({ name: 'mcp-hub', version: VERSION });
39
187
  const findServer = (name) => supervisor.get(name);
40
188
  /**
@@ -76,18 +224,24 @@ export function buildHubServer(supervisor) {
76
224
  title: 'List MCP servers',
77
225
  description: 'List all MCP servers available through this hub, with their status. Call this first to see what is available. ' +
78
226
  'Servers marked "hidden" serve their tools only via their own endpoint, but wake_server/sleep_server still manage them.',
79
- inputSchema: {}
80
- }, async () => text([...supervisor.servers.values()].map(s => ({
81
- name: s.name,
82
- description: s.serverInfo?.title ?? s.serverInfo?.name ?? '',
83
- status: s.state,
84
- toolCount: s.tools.length,
85
- ...(s.config.hub ? {} : { hidden: true })
86
- }))));
227
+ inputSchema: z.object({}),
228
+ outputSchema: listServersOutput,
229
+ annotations: READS
230
+ }, async () => structured({
231
+ servers: [...supervisor.servers.values()].map(s => ({
232
+ name: s.name,
233
+ description: s.serverInfo?.title ?? s.serverInfo?.name ?? '',
234
+ status: s.state,
235
+ toolCount: s.tools.length,
236
+ ...(s.config.hub ? {} : { hidden: true })
237
+ }))
238
+ }));
87
239
  hub.registerTool('list_tools', {
88
240
  title: 'List tools of a server',
89
241
  description: 'List the tools of one MCP server with one-line descriptions. Use get_tool_schema before calling a tool for the first time.',
90
- inputSchema: { server: z.string().describe('Server name from list_servers') }
242
+ inputSchema: z.object({ server: z.string().describe('Server name from list_servers') }),
243
+ outputSchema: listToolsOutput,
244
+ annotations: READS
91
245
  }, async ({ server }) => {
92
246
  const managed = findServer(server);
93
247
  if (!managed)
@@ -100,15 +254,32 @@ export function buildHubServer(supervisor) {
100
254
  const failed = await prepare(managed);
101
255
  if (failed)
102
256
  return failed;
103
- return text(managed.tools.map(t => ({ name: t.name, description: firstLine(t.description) })));
257
+ return structured({
258
+ server: managed.name,
259
+ tools: managed.tools.map(t => ({
260
+ name: t.name,
261
+ description: firstLine(t.description),
262
+ ...passThrough(t),
263
+ // A marker, not the schema itself: this list exists to stay small,
264
+ // and a page of JSON Schema per tool is what get_tool_schema is for.
265
+ // Omitted rather than false, for passThrough's reason.
266
+ //
267
+ // It stays a marker now that every server this family ships declares
268
+ // one. A hub runs whatever its config names, and a child written by
269
+ // somebody else is the ordinary case here, not the odd one.
270
+ ...(t.outputSchema === undefined ? {} : { hasOutputSchema: true })
271
+ }))
272
+ });
104
273
  });
105
274
  hub.registerTool('get_tool_schema', {
106
275
  title: 'Get the full schema of a tool',
107
276
  description: 'Get the full description and JSON input schema of one tool, needed to construct arguments for call_tool.',
108
- inputSchema: {
277
+ inputSchema: z.object({
109
278
  server: z.string().describe('Server name from list_servers'),
110
279
  tool: z.string().describe('Tool name from list_tools')
111
- }
280
+ }),
281
+ outputSchema: getToolSchemaOutput,
282
+ annotations: READS
112
283
  }, async ({ server, tool }) => {
113
284
  const managed = findServer(server);
114
285
  if (!managed)
@@ -128,17 +299,48 @@ export function buildHubServer(supervisor) {
128
299
  const found = managed.tools.find(t => t.name === tool);
129
300
  if (!found)
130
301
  return toolError(`Unknown tool "${tool}" on server "${server}". Use list_tools to see available tools.`);
131
- return text({ name: found.name, description: found.description ?? '', inputSchema: found.inputSchema });
302
+ return structured({
303
+ server: managed.name,
304
+ name: found.name,
305
+ description: found.description ?? '',
306
+ inputSchema: found.inputSchema,
307
+ // The reason this tool exists twice over: call_tool hands back the
308
+ // child's structuredContent, and until this line the caller had no way
309
+ // to learn what shape it was promised. Verbatim and omitted-when-absent,
310
+ // exactly as annotations are.
311
+ ...(found.outputSchema === undefined ? {} : { outputSchema: found.outputSchema }),
312
+ ...passThrough(found)
313
+ });
132
314
  });
133
315
  hub.registerTool('call_tool', {
134
316
  title: 'Call a tool on a server',
135
317
  description: 'Call a tool on one of the MCP servers. Arguments must match the schema from get_tool_schema.',
136
- inputSchema: {
318
+ inputSchema: z.object({
137
319
  server: z.string().describe('Server name from list_servers'),
138
320
  tool: z.string().describe('Tool name from list_tools'),
139
- arguments: z.record(z.string(), z.unknown()).optional().describe('Tool arguments matching its input schema')
140
- }
141
- }, async ({ server, tool, arguments: args }) => {
321
+ // `meta` for the same reason as `foreignDocument`: say `true` on the
322
+ // wire rather than the empty schema zod would write by itself.
323
+ arguments: z
324
+ .record(z.string(), z.unknown())
325
+ .meta({ additionalProperties: true })
326
+ .optional()
327
+ .describe('Tool arguments matching its input schema')
328
+ }),
329
+ // No outputSchema, and the only tool here without one. That is a
330
+ // construction rather than an omission: this tool answers with a child's
331
+ // result, so the shape is the child's own and changes with the `tool`
332
+ // argument. A schema here could say no more than "an object", which is
333
+ // less than the caller can already have — get_tool_schema returns the
334
+ // child's own outputSchema, and call_tool hands the matching
335
+ // structuredContent back verbatim.
336
+ //
337
+ // The one place the hub cannot know the answer, so it gives the strongest
338
+ // one. Whatever the named tool does, call_tool does — it may delete, it
339
+ // may reach the whole internet, and forwarding is not the same as
340
+ // vouching. Read the child's own annotations from list_tools for what a
341
+ // particular call would do.
342
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
343
+ }, async ({ server, tool, arguments: args }, ctx) => {
142
344
  const managed = findServer(server);
143
345
  if (!managed)
144
346
  return toolError(`Unknown server "${server}". Use list_servers to see available servers.`);
@@ -151,6 +353,10 @@ export function buildHubServer(supervisor) {
151
353
  const filtered = requireAllowedTool(managed, tool, server);
152
354
  if (filtered)
153
355
  return filtered;
356
+ // The wake stays here rather than being left to the forward, which would
357
+ // do it too: this endpoint answers with tool results, and "failed to
358
+ // start" is a different thing for a caller to read than "tool call
359
+ // failed". Once it is up the forward's own wake is a no-op.
154
360
  if (managed.onDemand && (managed.state !== 'up' || !managed.client)) {
155
361
  try {
156
362
  await managed.wake();
@@ -161,19 +367,34 @@ export function buildHubServer(supervisor) {
161
367
  }
162
368
  if (managed.state !== 'up' || !managed.client)
163
369
  return toolError(`Server "${server}" is ${managed.state}, try again later.`);
164
- managed.markUsed();
165
370
  try {
166
- const result = await managed.client.callTool({ name: tool, arguments: (args ?? {}) }, undefined, ABSOLUTE_CALL_OPTIONS);
167
- return assertForwardedResultSize(result);
371
+ // Same forwarding rules as the per-server path, from the same code: a
372
+ // child that asks something reaches the person through this door too.
373
+ // `via: 'hub'` binds the sealed request state to this endpoint — the
374
+ // same tool is reachable both ways, and resuming one call through the
375
+ // other door is not something a caller should be able to do.
376
+ return await forwardToolCall({
377
+ managed,
378
+ tool,
379
+ params: { name: tool, arguments: (args ?? {}) },
380
+ ctx,
381
+ secret,
382
+ via: 'hub'
383
+ });
168
384
  }
169
385
  catch (error) {
386
+ // Aggregate semantics: every failure here is a tool result, never a
387
+ // protocol error, because six meta-tools stand in for every server and
388
+ // one unreachable child must not look like a broken hub.
170
389
  return toolError(`Tool call failed: ${error.message}`);
171
390
  }
172
391
  });
173
392
  hub.registerTool('wake_server', {
174
393
  title: 'Wake a sleeping server',
175
394
  description: 'Start an on-demand server now so its first tool call is fast. No-op if it is already running.',
176
- inputSchema: { server: z.string().describe('Server name from list_servers') }
395
+ inputSchema: z.object({ server: z.string().describe('Server name from list_servers') }),
396
+ outputSchema: wakeServerOutput,
397
+ annotations: LIFECYCLE
177
398
  }, async ({ server }) => {
178
399
  const managed = findServer(server);
179
400
  if (!managed)
@@ -187,12 +408,14 @@ export function buildHubServer(supervisor) {
187
408
  return toolError(`Server "${server}" failed to start: ${error.message}`);
188
409
  }
189
410
  managed.markUsed();
190
- return text({ name: managed.name, status: managed.state, toolCount: managed.tools.length });
411
+ return structured({ name: managed.name, status: managed.state, toolCount: managed.tools.length });
191
412
  });
192
413
  hub.registerTool('sleep_server', {
193
414
  title: 'Put a server to sleep',
194
415
  description: 'Stop an on-demand server immediately instead of waiting for its idle timeout. It restarts automatically on the next tool call.',
195
- inputSchema: { server: z.string().describe('Server name from list_servers') }
416
+ inputSchema: z.object({ server: z.string().describe('Server name from list_servers') }),
417
+ outputSchema: sleepServerOutput,
418
+ annotations: LIFECYCLE
196
419
  }, async ({ server }) => {
197
420
  const managed = findServer(server);
198
421
  if (!managed)
@@ -200,8 +423,65 @@ export function buildHubServer(supervisor) {
200
423
  if (!managed.onDemand)
201
424
  return toolError(`Server "${server}" is always running.`);
202
425
  await managed.sleep();
203
- return text({ name: managed.name, status: managed.state });
426
+ return structured({ name: managed.name, status: managed.state });
204
427
  });
428
+ /**
429
+ * The seventh tool, off unless an operator asks for it.
430
+ *
431
+ * Off by default because every tool a client can see costs context in every
432
+ * conversation that client has, and "six meta-tools instead of N×tools" is
433
+ * the whole argument for the aggregate. This one answers a question most
434
+ * deployments never ask. It is not off for safety: it reports only what the
435
+ * caller's own request already contains, and says nothing about any other
436
+ * client — which is exactly why it exists instead of a tool that hands out
437
+ * the hub's log.
438
+ *
439
+ * Read here rather than at module load so a test can set it per case, and
440
+ * because this function runs once per request anyway.
441
+ */
442
+ if (booleanEnv('MCP_DIAGNOSTICS', false)) {
443
+ hub.registerTool('describe_connection', {
444
+ title: 'Describe this connection',
445
+ description: 'Report how you are connected to this hub right now: the protocol era, and whether a server could ask you a question mid-call. ' +
446
+ 'Name a server to include its own switch and era in the answer.',
447
+ inputSchema: z.object({
448
+ server: z.string().optional().describe('Server name from list_servers. Omit to ask about the connection alone.')
449
+ }),
450
+ outputSchema: describeConnectionOutput,
451
+ annotations: READS
452
+ }, async ({ server }, ctx) => {
453
+ const managed = server === undefined ? undefined : findServer(server);
454
+ if (server !== undefined && !managed) {
455
+ return toolError(`Unknown server "${server}". Use list_servers to see available servers.`);
456
+ }
457
+ // Exactly what forwardToolCall reads, from exactly the same place.
458
+ const envelope = ctx.mcpReq.envelope;
459
+ const declared = envelope?.[CLIENT_CAPABILITIES_META_KEY]?.elicitation;
460
+ // A sleeping child has negotiated no era, and asking here must not wake
461
+ // one: this tool reports, it does not act.
462
+ const childEra = managed?.state === 'up' ? managed.client?.getProtocolEra() : undefined;
463
+ const decision = decidePassthrough({ config: managed?.config, declaredElicitation: declared, childEra });
464
+ // Without a named server the first two conditions still hold for every
465
+ // server there is — an operator's global switch and the caller's own
466
+ // capability do not vary by child. The last two do, so an answer that
467
+ // stopped there is reported as "depends", not as a refusal the caller
468
+ // could act on.
469
+ const dependsOnAServer = managed === undefined && (decision.refusal === 'child-asleep' || decision.refusal === 'child-era');
470
+ return structured({
471
+ era,
472
+ revision: REVISION[era],
473
+ hubVersion: VERSION,
474
+ caller: { declaresElicitation: declared !== undefined },
475
+ elicitation: dependsOnAServer
476
+ ? { reason: 'you could be asked; whether a given server may ask depends on that server — name one to find out' }
477
+ : {
478
+ wouldForward: decision.forward,
479
+ ...(decision.refusal ? { reason: REFUSAL_REASON[decision.refusal] } : {}),
480
+ ...(managed ? { server: managed.name } : {})
481
+ }
482
+ });
483
+ });
484
+ }
205
485
  return hub;
206
486
  }
207
487
  //# sourceMappingURL=hub.js.map
package/dist/index.js CHANGED
@@ -1,17 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path';
3
3
  import express from 'express';
4
- import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
4
+ import { requireBearerAuth } from '@modelcontextprotocol/express';
5
5
  import { loadConfig, ConfigWatcher, warnMutableDockerImages } from './config.js';
6
6
  import { Supervisor, UpstreamAuthRegistry } from './supervisor.js';
7
7
  import { ToolCache } from './tool-cache.js';
8
8
  import { warnSingleFileMount } from './mount-check.js';
9
- import { serverRequestHandler, handleMcpRequest } from './proxy.js';
9
+ import { serverRequestHandler, handleMcpRequest, createRoute } from './proxy.js';
10
10
  import { buildHubServer } from './hub.js';
11
11
  import { AuthStore, DEFAULT_CLIENT_LIMITS } from './auth/store.js';
12
12
  import { CimdResolver } from './auth/cimd.js';
13
- import { HubOAuthProvider } from './auth/provider.js';
14
- import { createAuthRoutes } from './auth/routes.js';
13
+ import { createOidcInteractionRoutes } from './auth/oidc/interactions.js';
14
+ import { mountOidcProvider } from './auth/oidc/mount.js';
15
+ import { buildOidcProvider } from './auth/oidc/provider.js';
16
+ import { OidcTokenVerifier } from './auth/oidc/verifier.js';
17
+ import { authSecurityHeaders } from './auth/headers.js';
18
+ import { createProtectedResourceRoutes } from './auth/protected-resource.js';
19
+ import { createRegistrationManagementRoutes } from './auth/registration.js';
15
20
  import { createUpstreamRoutes } from './upstream/routes.js';
16
21
  import { healthHandler } from './health.js';
17
22
  import { installFileLogging } from './logfile.js';
@@ -19,6 +24,7 @@ import { canonicalResourceUrl, resourceUrlForRoute } from './auth/resource.js';
19
24
  import { ClientRequestGate } from './limits.js';
20
25
  import { runStdio } from './stdio.js';
21
26
  import { isMainModule } from './main-module.js';
27
+ import { IDLE_TIMEOUT_MS } from './timings.js';
22
28
  /** How often the hub looks for registrations that have aged out. The windows
23
29
  * are a day and three months, so this only has to be far below those. */
24
30
  const CLIENT_PRUNE_INTERVAL_MS = 15 * 60_000;
@@ -60,8 +66,10 @@ export async function createHub(options) {
60
66
  const idleTimeoutMinutes = options.idleTimeoutMinutes ?? 60;
61
67
  if (!Number.isSafeInteger(idleTimeoutMinutes) || idleTimeoutMinutes < 0)
62
68
  throw new Error('idleTimeoutMinutes must be a non-negative integer');
69
+ // IDLE_TIMEOUT_MS is the sub-minute sibling of the documented knob; see timings.ts.
70
+ const idleTimeoutMs = IDLE_TIMEOUT_MS || idleTimeoutMinutes * 60_000;
63
71
  const cache = new ToolCache(options.toolCachePath ?? path.join(options.dataPath, 'tool-cache.json'));
64
- if (idleTimeoutMinutes > 0) {
72
+ if (idleTimeoutMs > 0) {
65
73
  cache.load();
66
74
  if (!cache.probeWritable()) {
67
75
  console.warn(`mcp-hub: tool cache ${cache.filePath} is not writable — on-demand servers warm-start at every boot instead of sleeping through it`);
@@ -85,12 +93,22 @@ export async function createHub(options) {
85
93
  }
86
94
  }
87
95
  const upstreamAuth = new UpstreamAuthRegistry(store, externalUrl);
88
- const supervisor = new Supervisor(config, { idleTimeoutMinutes, cache, upstreamAuth });
96
+ const supervisor = new Supervisor(config, { idleTimeoutMinutes, idleTimeoutMs, cache, upstreamAuth });
89
97
  // start() before reapOrphans(): reaping spares the container of any server
90
98
  // that is not asleep, so it must see the boot states. Deliberately not
91
99
  // awaited: an unreachable Docker endpoint must not hold up the HTTP listener
92
100
  // or the stdio children. Children come up (or hydrate into `sleeping`) in
93
101
  // the background; paths answer 503 until then.
102
+ // One long-lived route for the aggregate, because the handler inside owns the
103
+ // open subscription streams. /hub carries tools and nothing else, so the only
104
+ // change worth relaying is a child's tool list moving; the supervisor
105
+ // publishes those here.
106
+ const hubRoute = createRoute(era => buildHubServer(supervisor, store.cookieSecret, era).server, {
107
+ label: '/hub',
108
+ // Every child, because the aggregate's tool list spans all of them.
109
+ onDemandChange: () => supervisor.reconcileAll()
110
+ });
111
+ supervisor.hubSubscriptions = hubRoute.registry;
94
112
  supervisor.start();
95
113
  void supervisor
96
114
  .reapOrphans()
@@ -112,13 +130,6 @@ export async function createHub(options) {
112
130
  const cimd = mechanisms.includes('cimd')
113
131
  ? new CimdResolver({ allowedOrigins: options.cimdAllowedOrigins, allowPrivateAddresses: options.cimdAllowPrivateAddresses })
114
132
  : undefined;
115
- const provider = new HubOAuthProvider(store, externalUrl, {
116
- requireResource,
117
- resolveResource: resource => canonicalResourceUrl(resource, origin, watcher.current),
118
- defaultResource: options.defaultResource !== undefined ? resourceUrlForRoute(origin, options.defaultResource) : undefined,
119
- cimd,
120
- allowDynamicRegistration: mechanisms.includes('dcr')
121
- });
122
133
  const app = express();
123
134
  if (!options.trustedProxies?.length) {
124
135
  // Without this every request behind a proxy reports the proxy's address,
@@ -127,14 +138,65 @@ export async function createHub(options) {
127
138
  }
128
139
  app.set('trust proxy', options.trustedProxies ?? false);
129
140
  app.disable('x-powered-by');
141
+ /**
142
+ * A TRUSTED_PROXIES list that never matches is worse than an empty one,
143
+ * because the empty one is announced above and this is silent.
144
+ *
145
+ * The list is compared against the address the connection actually came from,
146
+ * and the easiest way to get that wrong is also the least visible: a proxy
147
+ * configured as `proxy_pass http://localhost:…` on a dual-stack host connects
148
+ * over `::1`, which `127.0.0.1` does not cover. Every forwarded header is
149
+ * then ignored, `req.ip` becomes the proxy for every request, per-caller rate
150
+ * limiting collapses into one global counter and every fail2ban line names
151
+ * the proxy — the exact failure the warning above exists for, with nothing
152
+ * said about it.
153
+ *
154
+ * Diagnosed rather than assumed: a forwarded header arrived and was not
155
+ * believed, which is the observable half of the mistake. Said once.
156
+ */
157
+ if (options.trustedProxies?.length) {
158
+ let warnedAboutProxy = false;
159
+ app.use((req, _res, next) => {
160
+ if (!warnedAboutProxy && req.headers['x-forwarded-for'] !== undefined && req.ip === req.socket.remoteAddress) {
161
+ warnedAboutProxy = true;
162
+ console.warn(`mcp-hub: a request from ${req.socket.remoteAddress} carried X-Forwarded-For but that address is not in TRUSTED_PROXIES ` +
163
+ `(${options.trustedProxies?.join(', ')}), so the header was ignored — check for the 127.0.0.1 vs ::1 mismatch. ` +
164
+ 'Until it matches, per-caller rate limiting and the fail2ban log lines see only the proxy.');
165
+ }
166
+ next();
167
+ });
168
+ }
130
169
  // A liveness check intentionally carries no topology. Detailed child state
131
170
  // lives behind OAuth at /health.
132
171
  app.get('/livez', (_req, res) => res.status(200).json({ status: 'ok' }));
133
- app.use(createAuthRoutes({ provider, store, externalUrl, passwordHash: options.passwordHash, password: options.password, cimd }));
172
+ // The resource server's own discovery document, independent of whichever
173
+ // authorization server is mounted below.
174
+ app.use(createProtectedResourceRoutes({ externalUrl }));
175
+ const oidc = buildOidcProvider(store, {
176
+ externalUrl,
177
+ requireResource,
178
+ resolveResource: resource => canonicalResourceUrl(resource, origin, watcher.current),
179
+ defaultResource: options.defaultResource !== undefined ? resourceUrlForRoute(origin, options.defaultResource) : undefined,
180
+ allowDynamicRegistration: mechanisms.includes('dcr'),
181
+ cimd
182
+ });
183
+ app.use(createOidcInteractionRoutes({
184
+ provider: oidc,
185
+ store,
186
+ externalUrl,
187
+ password: options.password,
188
+ passwordHash: options.passwordHash,
189
+ cimd
190
+ }));
191
+ // Ahead of the mount, so the hub's stricter RFC 7592 handlers win the
192
+ // /register/:id route over oidc-provider's.
193
+ if (mechanisms.includes('dcr'))
194
+ app.use(createRegistrationManagementRoutes({ store, externalUrl }));
195
+ mountOidcProvider(app, oidc, store, { externalUrl, common: [authSecurityHeaders] });
134
196
  // The upstream callback and the hub's own client metadata document. Mounted
135
197
  // after the auth routes so it inherits nothing from them but sits ahead of
136
198
  // the /:name catch-all.
137
- app.use(createUpstreamRoutes({ store, provider, registry: upstreamAuth, supervisor, watcher, externalUrl }));
199
+ app.use(createUpstreamRoutes({ store, registry: upstreamAuth, supervisor, watcher, externalUrl }));
138
200
  // Registrations age out on a clock, not on traffic, so this cannot wait for
139
201
  // the next write to state.json — an idle hub would never clean up at all.
140
202
  // The first pass runs at boot, which is also what gives clients from a state
@@ -153,11 +215,27 @@ export async function createHub(options) {
153
215
  sweepClients();
154
216
  const pruneTimer = setInterval(sweepClients, CLIENT_PRUNE_INTERVAL_MS);
155
217
  pruneTimer.unref(); // never a reason to keep the process alive
156
- const stopMaintenance = () => clearInterval(pruneTimer);
218
+ const stopMaintenance = () => {
219
+ clearInterval(pruneTimer);
220
+ // The aggregate's handler holds whatever listen streams are open on /hub;
221
+ // nothing else closes it, because it is not owned by a ManagedServer.
222
+ void hubRoute.close().catch(() => { });
223
+ };
157
224
  // Bearer auth for the MCP endpoints, advertising the path-scoped RFC 9728
158
225
  // metadata document in WWW-Authenticate so clients discover the AS.
226
+ const verifier = new OidcTokenVerifier(store, {
227
+ externalUrl,
228
+ requireResource,
229
+ resolveResource: resource => canonicalResourceUrl(resource, origin, watcher.current)
230
+ });
231
+ // From @modelcontextprotocol/express, not the frozen server-legacy copy the
232
+ // codemod reaches for by default. That copy exists for projects still running
233
+ // the SDK's own authorization server; the hub replaced its own with
234
+ // oidc-provider first, precisely so this dependency never had to be taken on.
235
+ // The one thing the maintained middleware needs in return is that the
236
+ // verifier throws v2's OAuthError -- see OidcTokenVerifier.
159
237
  const bearer = (req, res, next) => requireBearerAuth({
160
- verifier: provider,
238
+ verifier,
161
239
  resourceMetadataUrl: `${origin}/.well-known/oauth-protected-resource${req.path === '/' ? '' : req.path}`
162
240
  })(req, res, next);
163
241
  // A bound token may only reach the resource it was issued for. An unbound one
@@ -187,7 +265,7 @@ export async function createHub(options) {
187
265
  const parseMcpJson = express.json({ limit: options.mcpBodyLimit ?? '1mb' });
188
266
  const dispatch = (name) => async (req, res, next) => {
189
267
  if (name === 'hub') {
190
- await handleMcpRequest(() => buildHubServer(supervisor).server, req, res);
268
+ await handleMcpRequest(hubRoute, req, res);
191
269
  return;
192
270
  }
193
271
  const managed = supervisor.get(name);
@@ -195,10 +273,14 @@ export async function createHub(options) {
195
273
  next(); // fall through to 404
196
274
  return;
197
275
  }
198
- await serverRequestHandler(managed)(req, res);
276
+ await serverRequestHandler(managed, store.cookieSecret)(req, res);
199
277
  };
200
278
  for (const route of ['/:name', '/:name/mcp']) {
201
- app.all(route, bearer, gate.middleware, requireRouteResource, parseMcpJson, (req, res, next) => void dispatch(String(req.params.name))(req, res, next).catch(next));
279
+ // parseMcpJson ahead of the gate, which is a change of order: the gate has
280
+ // to tell a `subscriptions/listen` POST from an ordinary one, and that only
281
+ // shows in the parsed body. Authorization still runs first, so the parse is
282
+ // bounded by mcpBodyLimit and only ever done for a caller allowed to be here.
283
+ app.all(route, bearer, requireRouteResource, parseMcpJson, gate.middleware, (req, res, next) => void dispatch(String(req.params.name))(req, res, next).catch(next));
202
284
  }
203
285
  // Express only recognises a four-argument middleware as an error handler.
204
286
  // Without it a throw out of the proxy path would escape as an unhandled
@@ -215,7 +297,7 @@ export async function createHub(options) {
215
297
  }
216
298
  res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null });
217
299
  });
218
- return { app, supervisor, watcher, provider, store, upstreamAuth, stopMaintenance };
300
+ return { app, supervisor, watcher, verifier, store, upstreamAuth, stopMaintenance };
219
301
  }
220
302
  function requireEnv(name) {
221
303
  const value = process.env[name];