@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/hub.js
CHANGED
|
@@ -1,11 +1,118 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
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 {
|
|
4
|
+
import { forwardToolCall } from './forward.js';
|
|
5
5
|
import { loggableToolName, toolAllowed } from './tool-filter.js';
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
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/
|
|
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 {
|
|
14
|
-
import {
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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 = () =>
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
|
300
|
+
return { app, supervisor, watcher, verifier, store, upstreamAuth, stopMaintenance };
|
|
219
301
|
}
|
|
220
302
|
function requireEnv(name) {
|
|
221
303
|
const value = process.env[name];
|