@winstonsayno/mcp-gateway 1.1.0 → 1.3.0
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 +111 -0
- package/README.md +43 -7
- package/dashboard/README.md +4 -0
- package/dashboard/index.html +42 -3
- package/dist/auth/middleware.d.ts +38 -1
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +148 -19
- package/dist/auth/middleware.js.map +1 -1
- package/dist/auth/oauth.d.ts +86 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +342 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/cli.js +54 -1
- package/dist/cli.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +141 -3
- package/dist/config/loader.js.map +1 -1
- package/dist/gateway/api.d.ts +5 -0
- package/dist/gateway/api.d.ts.map +1 -1
- package/dist/gateway/api.js +97 -6
- package/dist/gateway/api.js.map +1 -1
- package/dist/gateway/index.d.ts +9 -1
- package/dist/gateway/index.d.ts.map +1 -1
- package/dist/gateway/index.js +90 -5
- package/dist/gateway/index.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/catalog.d.ts +1 -1
- package/dist/mcp/endpoint.d.ts +52 -1
- package/dist/mcp/endpoint.d.ts.map +1 -1
- package/dist/mcp/endpoint.js +339 -12
- package/dist/mcp/endpoint.js.map +1 -1
- package/dist/middleware/error-handler.d.ts +10 -1
- package/dist/middleware/error-handler.d.ts.map +1 -1
- package/dist/middleware/error-handler.js +17 -3
- package/dist/middleware/error-handler.js.map +1 -1
- package/dist/monitor/index.d.ts.map +1 -1
- package/dist/monitor/index.js +5 -0
- package/dist/monitor/index.js.map +1 -1
- package/dist/proxy/index.d.ts +15 -0
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +44 -4
- package/dist/proxy/index.js.map +1 -1
- package/dist/security/headers.d.ts +23 -0
- package/dist/security/headers.d.ts.map +1 -0
- package/dist/security/headers.js +71 -0
- package/dist/security/headers.js.map +1 -0
- package/dist/security/lockout.d.ts +41 -0
- package/dist/security/lockout.d.ts.map +1 -0
- package/dist/security/lockout.js +117 -0
- package/dist/security/lockout.js.map +1 -0
- package/dist/security/network.d.ts +31 -0
- package/dist/security/network.d.ts.map +1 -0
- package/dist/security/network.js +147 -0
- package/dist/security/network.js.map +1 -0
- package/dist/security/posture.d.ts +18 -0
- package/dist/security/posture.d.ts.map +1 -0
- package/dist/security/posture.js +76 -0
- package/dist/security/posture.js.map +1 -0
- package/dist/security/redact.d.ts +34 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +115 -0
- package/dist/security/redact.js.map +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +4 -2
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/types.d.ts +119 -6
- package/dist/utils/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/mcp/endpoint.js
CHANGED
|
@@ -5,9 +5,16 @@
|
|
|
5
5
|
* - `POST <path>`: JSON-RPC requests / notifications / responses (batches
|
|
6
6
|
* accepted for 2025-03-26 clients). Answers with `application/json`.
|
|
7
7
|
* - `GET <path>`: server→client SSE stream for notifications
|
|
8
|
-
* (`notifications/tools/list_changed
|
|
8
|
+
* (`notifications/{tools,resources,prompts}/list_changed`,
|
|
9
|
+
* `notifications/resources/updated`, `notifications/message`).
|
|
9
10
|
* - `DELETE <path>`: ends the session.
|
|
10
11
|
*
|
|
12
|
+
* A `tools/call` carrying `_meta.progressToken` from a client that accepts
|
|
13
|
+
* `text/event-stream` is answered as an SSE stream as soon as the upstream
|
|
14
|
+
* server reports progress: `notifications/progress` events, then the result.
|
|
15
|
+
* `logging/setLevel`, `completion/complete` and `resources/subscribe` /
|
|
16
|
+
* `unsubscribe` are routed to the owning upstream servers.
|
|
17
|
+
*
|
|
11
18
|
* Sessions (`Mcp-Session-Id`) are bound to the authenticated client: a
|
|
12
19
|
* session id presented with another key is treated as unknown. Auth, rate
|
|
13
20
|
* limiting (per `tools/call`), `maxConcurrency`, timeouts, metrics and the
|
|
@@ -20,12 +27,17 @@ import { randomUUID, createHash } from 'crypto';
|
|
|
20
27
|
import { ERR_CANCELLED, ERR_NOT_CONNECTED, ERR_TIMEOUT } from '../proxy/index.js';
|
|
21
28
|
import { setRateLimitHeaders } from '../auth/ratelimit.js';
|
|
22
29
|
import { originAllowed } from '../middleware/cors.js';
|
|
30
|
+
import { isLoopbackOrigin, isSameOrigin } from '../security/network.js';
|
|
23
31
|
import { filterToolsByScope, isToolInScope } from '../auth/scopes.js';
|
|
24
32
|
import { logger } from '../utils/logger.js';
|
|
25
33
|
import { VERSION } from '../utils/version.js';
|
|
26
34
|
import { buildToolIndex, toMcpTool } from './naming.js';
|
|
27
35
|
import { buildPromptIndex, dedupeResources, routeResource, toMcpPrompt, toMcpResource, toMcpResourceTemplate, } from './catalog.js';
|
|
28
36
|
import { isServerInScope } from '../auth/scopes.js';
|
|
37
|
+
import { matchesUriTemplate } from './catalog.js';
|
|
38
|
+
/** MCP logging levels (RFC 5424 severities), least severe first. */
|
|
39
|
+
export const LOG_LEVELS = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
|
|
40
|
+
const levelIndex = (l) => LOG_LEVELS.indexOf(l);
|
|
29
41
|
/** Protocol versions the endpoint speaks, newest first. */
|
|
30
42
|
export const DOWNSTREAM_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26'];
|
|
31
43
|
export const LATEST_PROTOCOL_VERSION = DOWNSTREAM_PROTOCOL_VERSIONS[0];
|
|
@@ -46,6 +58,7 @@ export const DEFAULT_MCP_CONFIG = {
|
|
|
46
58
|
pageSize: 500,
|
|
47
59
|
sessionIdleTimeoutSeconds: 1800,
|
|
48
60
|
maxSessions: 1000,
|
|
61
|
+
eventBufferSize: 256,
|
|
49
62
|
};
|
|
50
63
|
const identityOf = (req) => ({
|
|
51
64
|
clientId: req.clientId,
|
|
@@ -84,12 +97,23 @@ export class McpEndpoint {
|
|
|
84
97
|
notifyTimer;
|
|
85
98
|
closed = false;
|
|
86
99
|
onRegistryChange = () => this.scheduleListChanged();
|
|
100
|
+
/** Upstream resource subscriptions: `<serverId>\0<uri>` → subscribed session ids. */
|
|
101
|
+
upstreamSubs = new Map();
|
|
102
|
+
/** Last `logging/setLevel` sent to each upstream server. */
|
|
103
|
+
upstreamLogLevel = new Map();
|
|
104
|
+
onUpstreamNotification = (serverId, msg) => this.handleUpstreamNotification(serverId, msg);
|
|
105
|
+
onUpstreamConnected = (serverId) => this.handleUpstreamConnected(serverId);
|
|
106
|
+
onUpstreamDisconnected = (serverId) => this.upstreamLogLevel.delete(serverId);
|
|
107
|
+
jsonParser;
|
|
87
108
|
constructor(config, deps) {
|
|
88
109
|
this.deps = deps;
|
|
89
110
|
this.cfg = { ...DEFAULT_MCP_CONFIG, ...stripUndefined(config ?? {}) };
|
|
90
111
|
deps.registry.on('tools-updated', this.onRegistryChange);
|
|
91
112
|
deps.registry.on('unregistered', this.onRegistryChange);
|
|
92
113
|
deps.registry.on('catalog-updated', this.onRegistryChange);
|
|
114
|
+
deps.proxy.on('notification', this.onUpstreamNotification);
|
|
115
|
+
deps.proxy.on('connected', this.onUpstreamConnected);
|
|
116
|
+
deps.proxy.on('disconnected', this.onUpstreamDisconnected);
|
|
93
117
|
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
|
|
94
118
|
this.sweepTimer.unref();
|
|
95
119
|
this.keepaliveTimer = setInterval(() => this.keepalive(), SSE_KEEPALIVE_MS);
|
|
@@ -112,7 +136,13 @@ export class McpEndpoint {
|
|
|
112
136
|
/** Express router serving POST / GET / DELETE on the configured path. */
|
|
113
137
|
router() {
|
|
114
138
|
const r = express.Router();
|
|
115
|
-
const json =
|
|
139
|
+
const json = (req, res, next) => {
|
|
140
|
+
const limit = this.deps.maxBodyBytes?.() ?? 10 * 1024 * 1024;
|
|
141
|
+
if (this.jsonParser?.limit !== limit) {
|
|
142
|
+
this.jsonParser = { limit, mw: express.json({ limit, type: ['application/json', 'application/*+json'] }) };
|
|
143
|
+
}
|
|
144
|
+
this.jsonParser.mw(req, res, next);
|
|
145
|
+
};
|
|
116
146
|
const path = this.cfg.path;
|
|
117
147
|
const guard = (req, res, next) => this.checkOrigin(req, res, next);
|
|
118
148
|
r.post(path, guard, this.deps.authenticate, json, (req, res, next) => {
|
|
@@ -150,6 +180,9 @@ export class McpEndpoint {
|
|
|
150
180
|
this.deps.registry.off('tools-updated', this.onRegistryChange);
|
|
151
181
|
this.deps.registry.off('unregistered', this.onRegistryChange);
|
|
152
182
|
this.deps.registry.off('catalog-updated', this.onRegistryChange);
|
|
183
|
+
this.deps.proxy.off('notification', this.onUpstreamNotification);
|
|
184
|
+
this.deps.proxy.off('connected', this.onUpstreamConnected);
|
|
185
|
+
this.deps.proxy.off('disconnected', this.onUpstreamDisconnected);
|
|
153
186
|
for (const s of [...this.sessions.values()])
|
|
154
187
|
this.endSession(s);
|
|
155
188
|
}
|
|
@@ -171,8 +204,15 @@ export class McpEndpoint {
|
|
|
171
204
|
// requests; non-browser clients usually send none.
|
|
172
205
|
const origin = req.headers.origin;
|
|
173
206
|
if (origin) {
|
|
174
|
-
const
|
|
175
|
-
|
|
207
|
+
const configured = this.cfg.allowedOrigins ?? this.deps.corsOrigins();
|
|
208
|
+
const strict = this.deps.strictOrigins?.() === true;
|
|
209
|
+
const ok = strict
|
|
210
|
+
? isSameOrigin(origin, req.headers.host) ||
|
|
211
|
+
isLoopbackOrigin(origin) ||
|
|
212
|
+
originAllowed((configured ?? []).filter((o) => o !== '*'), origin)
|
|
213
|
+
: originAllowed(configured ?? ['*'], origin);
|
|
214
|
+
if (!ok) {
|
|
215
|
+
logger.warn(`Rejected /mcp request from Origin ${origin}`);
|
|
176
216
|
res.status(403).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Origin not allowed' }));
|
|
177
217
|
return;
|
|
178
218
|
}
|
|
@@ -231,12 +271,15 @@ export class McpEndpoint {
|
|
|
231
271
|
for (const c of controllers)
|
|
232
272
|
c.abort();
|
|
233
273
|
});
|
|
274
|
+
// Single requests from clients accepting SSE may be upgraded to an SSE
|
|
275
|
+
// reply (progress notifications before the result).
|
|
276
|
+
const stream = !batch && acceptsEventStream(req) ? this.replyStream(res, session) : undefined;
|
|
234
277
|
const replies = await Promise.all(requests.map((m) => {
|
|
235
278
|
const ctrl = new AbortController();
|
|
236
279
|
controllers.push(ctrl);
|
|
237
280
|
const key = idKey(m.id);
|
|
238
281
|
session.inflight.set(key, ctrl);
|
|
239
|
-
return this.handleRequest(session, req, res, m, ctrl.signal, !batch)
|
|
282
|
+
return this.handleRequest(session, req, res, m, ctrl.signal, !batch, stream)
|
|
240
283
|
.catch((err) => {
|
|
241
284
|
logger.error(`MCP ${m.method} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
242
285
|
return rpcError(m.id, { code: JSONRPC_INTERNAL_ERROR, message: 'Internal error' });
|
|
@@ -246,10 +289,39 @@ export class McpEndpoint {
|
|
|
246
289
|
session.inflight.delete(key);
|
|
247
290
|
});
|
|
248
291
|
}));
|
|
292
|
+
if (stream?.started) {
|
|
293
|
+
if (!res.writableEnded) {
|
|
294
|
+
stream.send(replies[0]);
|
|
295
|
+
res.end();
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
249
299
|
if (res.headersSent || res.destroyed)
|
|
250
300
|
return;
|
|
251
301
|
res.status(200).json(batch ? replies : replies[0]);
|
|
252
302
|
}
|
|
303
|
+
replyStream(res, session) {
|
|
304
|
+
const stream = {
|
|
305
|
+
started: false,
|
|
306
|
+
send: (msg) => {
|
|
307
|
+
if (res.writableEnded || res.destroyed)
|
|
308
|
+
return;
|
|
309
|
+
if (!stream.started) {
|
|
310
|
+
stream.started = true;
|
|
311
|
+
res.status(200).set({
|
|
312
|
+
'Content-Type': 'text/event-stream',
|
|
313
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
314
|
+
Connection: 'keep-alive',
|
|
315
|
+
'X-Accel-Buffering': 'no',
|
|
316
|
+
'Mcp-Session-Id': session.id,
|
|
317
|
+
});
|
|
318
|
+
res.flushHeaders();
|
|
319
|
+
}
|
|
320
|
+
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
return stream;
|
|
324
|
+
}
|
|
253
325
|
initialize(req, res, msg) {
|
|
254
326
|
const params = isObject(msg.params) ? msg.params : {};
|
|
255
327
|
const requested = typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined;
|
|
@@ -274,7 +346,9 @@ export class McpEndpoint {
|
|
|
274
346
|
streams: [],
|
|
275
347
|
inflight: new Map(),
|
|
276
348
|
eventSeq: 0,
|
|
349
|
+
eventLog: [],
|
|
277
350
|
auth: identityOf(req),
|
|
351
|
+
subscriptions: new Set(),
|
|
278
352
|
};
|
|
279
353
|
session.toolsFingerprint = this.fingerprint(this.toolIndex(session.auth));
|
|
280
354
|
session.resourcesFingerprint = this.resourcesFingerprint(session.auth);
|
|
@@ -286,8 +360,10 @@ export class McpEndpoint {
|
|
|
286
360
|
protocolVersion,
|
|
287
361
|
capabilities: {
|
|
288
362
|
tools: { listChanged: true },
|
|
289
|
-
resources: { listChanged: true },
|
|
363
|
+
resources: { listChanged: true, subscribe: true },
|
|
290
364
|
prompts: { listChanged: true },
|
|
365
|
+
logging: {},
|
|
366
|
+
completions: {},
|
|
291
367
|
},
|
|
292
368
|
serverInfo: { name: 'mcp-gateway', title: 'mcp-gateway', version: VERSION },
|
|
293
369
|
};
|
|
@@ -334,6 +410,16 @@ export class McpEndpoint {
|
|
|
334
410
|
});
|
|
335
411
|
res.flushHeaders();
|
|
336
412
|
res.write(': connected\n\n');
|
|
413
|
+
// Resumability: replay events the client missed after a dropped stream.
|
|
414
|
+
const lastEventId = req.headers['last-event-id'];
|
|
415
|
+
if (typeof lastEventId === 'string' && /^\d+$/.test(lastEventId.trim())) {
|
|
416
|
+
const after = Number(lastEventId.trim());
|
|
417
|
+
const missed = session.eventLog.filter((e) => e.id > after);
|
|
418
|
+
for (const e of missed)
|
|
419
|
+
res.write(`id: ${e.id}\nevent: message\ndata: ${e.data}\n\n`);
|
|
420
|
+
if (missed.length)
|
|
421
|
+
logger.debug(`MCP session ${session.id.slice(0, 8)} resumed: replayed ${missed.length} event(s)`);
|
|
422
|
+
}
|
|
337
423
|
session.streams.push(res);
|
|
338
424
|
req.socket.setTimeout(0);
|
|
339
425
|
res.on('close', () => {
|
|
@@ -360,10 +446,18 @@ export class McpEndpoint {
|
|
|
360
446
|
}
|
|
361
447
|
// notifications/initialized and others need no action.
|
|
362
448
|
}
|
|
363
|
-
async handleRequest(session, req, res, msg, signal, single) {
|
|
449
|
+
async handleRequest(session, req, res, msg, signal, single, stream) {
|
|
364
450
|
const id = msg.id;
|
|
365
451
|
const ok = (result) => ({ jsonrpc: '2.0', id, result });
|
|
366
452
|
switch (msg.method) {
|
|
453
|
+
case 'logging/setLevel':
|
|
454
|
+
return this.setLogLevel(session, msg);
|
|
455
|
+
case 'completion/complete':
|
|
456
|
+
return this.complete(session, msg, signal);
|
|
457
|
+
case 'resources/subscribe':
|
|
458
|
+
return this.subscribe(session, msg);
|
|
459
|
+
case 'resources/unsubscribe':
|
|
460
|
+
return this.unsubscribe(session, msg);
|
|
367
461
|
case 'ping':
|
|
368
462
|
return ok({});
|
|
369
463
|
case 'initialize':
|
|
@@ -371,7 +465,7 @@ export class McpEndpoint {
|
|
|
371
465
|
case 'tools/list':
|
|
372
466
|
return this.listTools(session, msg);
|
|
373
467
|
case 'tools/call':
|
|
374
|
-
return this.callTool(session, req, res, msg, signal, single);
|
|
468
|
+
return this.callTool(session, req, res, msg, signal, single, stream);
|
|
375
469
|
case 'resources/list': {
|
|
376
470
|
const list = this.resources(session.auth);
|
|
377
471
|
if (isFirstPage(msg))
|
|
@@ -488,6 +582,9 @@ export class McpEndpoint {
|
|
|
488
582
|
}
|
|
489
583
|
if (!isObject(args))
|
|
490
584
|
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: '"arguments" must be an object' });
|
|
585
|
+
const tooLarge = this.argumentsTooLarge(msg.id, args);
|
|
586
|
+
if (tooLarge)
|
|
587
|
+
return tooLarge;
|
|
491
588
|
const prompt = this.promptIndex(session.auth).byName.get(name);
|
|
492
589
|
if (!prompt)
|
|
493
590
|
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: `Unknown prompt: ${name}` });
|
|
@@ -536,7 +633,7 @@ export class McpEndpoint {
|
|
|
536
633
|
result.nextCursor = encodeCursor(next);
|
|
537
634
|
return { jsonrpc: '2.0', id: msg.id, result };
|
|
538
635
|
}
|
|
539
|
-
async callTool(session, req, res, msg, signal, single) {
|
|
636
|
+
async callTool(session, req, res, msg, signal, single, stream) {
|
|
540
637
|
const id = msg.id;
|
|
541
638
|
const params = isObject(msg.params) ? msg.params : {};
|
|
542
639
|
const name = params.name;
|
|
@@ -547,6 +644,14 @@ export class McpEndpoint {
|
|
|
547
644
|
if (!isObject(args)) {
|
|
548
645
|
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"arguments" must be an object' });
|
|
549
646
|
}
|
|
647
|
+
const tooLarge = this.argumentsTooLarge(id, args);
|
|
648
|
+
if (tooLarge)
|
|
649
|
+
return tooLarge;
|
|
650
|
+
const meta = isObject(params._meta) ? params._meta : {};
|
|
651
|
+
const progressToken = typeof meta.progressToken === 'string' || typeof meta.progressToken === 'number' ? meta.progressToken : undefined;
|
|
652
|
+
const onProgress = progressToken !== undefined && stream
|
|
653
|
+
? (u) => stream.send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken, ...u } })
|
|
654
|
+
: undefined;
|
|
550
655
|
const tool = this.toolIndex(session.auth).byName.get(name);
|
|
551
656
|
if (!tool) {
|
|
552
657
|
// A tool that exists but is outside the client's scope is refused with
|
|
@@ -578,7 +683,7 @@ export class McpEndpoint {
|
|
|
578
683
|
const status = this.deps.registry.getHealth(serverId)?.status;
|
|
579
684
|
return toolError(`Server "${serverId}" is not connected${status ? ` (${status})` : ''}; try again later.`);
|
|
580
685
|
}
|
|
581
|
-
const result = await this.deps.proxy.callTool(serverId, tool.name, args, server.timeout, { signal });
|
|
686
|
+
const result = await this.deps.proxy.callTool(serverId, tool.name, args, server.timeout, { signal, onProgress });
|
|
582
687
|
this.deps.metrics.record({
|
|
583
688
|
serverId,
|
|
584
689
|
toolName: tool.name,
|
|
@@ -606,6 +711,213 @@ export class McpEndpoint {
|
|
|
606
711
|
return rpcError(id, err);
|
|
607
712
|
}
|
|
608
713
|
}
|
|
714
|
+
argumentsTooLarge(id, args) {
|
|
715
|
+
const limit = this.deps.maxArgumentsBytes?.() ?? 0;
|
|
716
|
+
if (!limit || Buffer.byteLength(JSON.stringify(args ?? {}), 'utf8') <= limit)
|
|
717
|
+
return undefined;
|
|
718
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: `"arguments" exceed the gateway limit of ${limit} bytes` });
|
|
719
|
+
}
|
|
720
|
+
// ─── Logging ────────────────────────────────────────────────────────────────
|
|
721
|
+
setLogLevel(session, msg) {
|
|
722
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
723
|
+
if (levelIndex(params.level) < 0) {
|
|
724
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: `"level" must be one of: ${LOG_LEVELS.join(', ')}` });
|
|
725
|
+
}
|
|
726
|
+
session.logLevel = params.level;
|
|
727
|
+
this.pushUpstreamLogLevel();
|
|
728
|
+
return { jsonrpc: '2.0', id: msg.id, result: {} };
|
|
729
|
+
}
|
|
730
|
+
/** Most verbose level any session asked for (undefined when none did). */
|
|
731
|
+
wantedLogLevel() {
|
|
732
|
+
let min = -1;
|
|
733
|
+
for (const s of this.sessions.values()) {
|
|
734
|
+
const i = levelIndex(s.logLevel);
|
|
735
|
+
if (i >= 0 && (min < 0 || i < min))
|
|
736
|
+
min = i;
|
|
737
|
+
}
|
|
738
|
+
return min >= 0 ? LOG_LEVELS[min] : undefined;
|
|
739
|
+
}
|
|
740
|
+
/** Send `logging/setLevel` to upstream servers with the `logging` capability when the wanted level changed. */
|
|
741
|
+
pushUpstreamLogLevel(only) {
|
|
742
|
+
const level = this.wantedLogLevel();
|
|
743
|
+
if (!level)
|
|
744
|
+
return;
|
|
745
|
+
const ids = only ? [only] : this.deps.registry.getEnabledServers().map((s) => s.id);
|
|
746
|
+
for (const serverId of ids) {
|
|
747
|
+
if (!this.deps.proxy.hasCapability(serverId, 'logging'))
|
|
748
|
+
continue;
|
|
749
|
+
if (this.upstreamLogLevel.get(serverId) === level)
|
|
750
|
+
continue;
|
|
751
|
+
this.upstreamLogLevel.set(serverId, level);
|
|
752
|
+
void this.deps.proxy.request(serverId, 'logging/setLevel', { level }, 5_000).then((r) => {
|
|
753
|
+
if (!r.success)
|
|
754
|
+
logger.debug(`[${serverId}] logging/setLevel failed: ${r.error?.message}`);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// ─── Completion ─────────────────────────────────────────────────────────────
|
|
759
|
+
async complete(session, msg, signal) {
|
|
760
|
+
const id = msg.id;
|
|
761
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
762
|
+
const ref = isObject(params.ref) ? params.ref : undefined;
|
|
763
|
+
const argument = isObject(params.argument) ? params.argument : undefined;
|
|
764
|
+
if (!ref || !argument || typeof argument.name !== 'string' || typeof argument.value !== 'string') {
|
|
765
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"ref" and "argument" { name, value } are required' });
|
|
766
|
+
}
|
|
767
|
+
const tooLarge = this.argumentsTooLarge(id, params.context ?? {});
|
|
768
|
+
if (tooLarge)
|
|
769
|
+
return tooLarge;
|
|
770
|
+
let serverId;
|
|
771
|
+
let upstreamRef = ref;
|
|
772
|
+
if (ref.type === 'ref/prompt' && typeof ref.name === 'string') {
|
|
773
|
+
const prompt = this.promptIndex(session.auth).byName.get(ref.name);
|
|
774
|
+
if (!prompt)
|
|
775
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: `Unknown prompt: ${ref.name}` });
|
|
776
|
+
serverId = prompt.serverId;
|
|
777
|
+
upstreamRef = { ...ref, name: prompt.name };
|
|
778
|
+
}
|
|
779
|
+
else if (ref.type === 'ref/resource' && typeof ref.uri === 'string') {
|
|
780
|
+
const uri = ref.uri;
|
|
781
|
+
const template = this.templates(session.auth).find((t) => t.uriTemplate === uri);
|
|
782
|
+
serverId =
|
|
783
|
+
template?.serverId ??
|
|
784
|
+
this.resources(session.auth).find((r) => r.uri === uri)?.serverId ??
|
|
785
|
+
this.templates(session.auth).find((t) => matchesUriTemplate(t.uriTemplate, uri))?.serverId;
|
|
786
|
+
if (!serverId)
|
|
787
|
+
return rpcError(id, { code: -32002, message: 'Resource not found', data: { uri } });
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"ref.type" must be "ref/prompt" or "ref/resource"' });
|
|
791
|
+
}
|
|
792
|
+
const empty = { completion: { values: [], hasMore: false } };
|
|
793
|
+
if (!this.deps.proxy.isConnected(serverId) || !this.deps.proxy.hasCapability(serverId, 'completions')) {
|
|
794
|
+
return { jsonrpc: '2.0', id, result: empty };
|
|
795
|
+
}
|
|
796
|
+
const upstreamParams = { ref: upstreamRef, argument };
|
|
797
|
+
if (isObject(params.context))
|
|
798
|
+
upstreamParams.context = params.context;
|
|
799
|
+
const server = this.deps.registry.getServer(serverId);
|
|
800
|
+
const r = await this.deps.proxy.request(serverId, 'completion/complete', upstreamParams, server?.timeout, { signal });
|
|
801
|
+
if (r.success)
|
|
802
|
+
return { jsonrpc: '2.0', id, result: r.result ?? empty };
|
|
803
|
+
if (r.error?.code === ERR_CANCELLED)
|
|
804
|
+
return rpcError(id, { code: ERR_CANCELLED, message: 'Request cancelled' });
|
|
805
|
+
return rpcError(id, r.error ?? { code: JSONRPC_INTERNAL_ERROR, message: 'Unknown error' });
|
|
806
|
+
}
|
|
807
|
+
// ─── Resource subscriptions ─────────────────────────────────────────────────
|
|
808
|
+
routeUri(session, uri) {
|
|
809
|
+
const scope = session.auth.scope;
|
|
810
|
+
return routeResource(uri, this.resources(session.auth), this.templates(session.auth), this.deps.registry
|
|
811
|
+
.getEnabledServers()
|
|
812
|
+
.map((s) => s.id)
|
|
813
|
+
.filter((sid) => isServerInScope(scope, sid) && this.deps.proxy.hasCapability(sid, 'resources')));
|
|
814
|
+
}
|
|
815
|
+
async subscribe(session, msg) {
|
|
816
|
+
const id = msg.id;
|
|
817
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
818
|
+
const uri = params.uri;
|
|
819
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
820
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"uri" must be a non-empty string' });
|
|
821
|
+
}
|
|
822
|
+
const serverId = this.routeUri(session, uri);
|
|
823
|
+
if (!serverId)
|
|
824
|
+
return rpcError(id, { code: -32002, message: 'Resource not found', data: { uri } });
|
|
825
|
+
const caps = this.deps.proxy.getSessionInfo(serverId)?.capabilities;
|
|
826
|
+
const resCaps = isObject(caps?.resources) ? caps.resources : {};
|
|
827
|
+
if (resCaps.subscribe !== true) {
|
|
828
|
+
return rpcError(id, { code: JSONRPC_METHOD_NOT_FOUND, message: `Server "${serverId}" does not support resource subscriptions` });
|
|
829
|
+
}
|
|
830
|
+
const key = `${serverId}\u0000${uri}`;
|
|
831
|
+
if (session.subscriptions.has(key))
|
|
832
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
833
|
+
let subs = this.upstreamSubs.get(key);
|
|
834
|
+
if (!subs || subs.size === 0) {
|
|
835
|
+
const r = await this.deps.proxy.request(serverId, 'resources/subscribe', { uri }, 10_000);
|
|
836
|
+
if (!r.success)
|
|
837
|
+
return rpcError(id, r.error ?? { code: JSONRPC_INTERNAL_ERROR, message: 'Subscribe failed' });
|
|
838
|
+
subs = this.upstreamSubs.get(key) ?? new Set();
|
|
839
|
+
this.upstreamSubs.set(key, subs);
|
|
840
|
+
}
|
|
841
|
+
if (!this.sessions.has(session.id)) {
|
|
842
|
+
// The session ended while subscribing.
|
|
843
|
+
if (subs.size === 0)
|
|
844
|
+
this.dropUpstreamSub(key);
|
|
845
|
+
return rpcError(id, { code: -32001, message: 'Session not found' });
|
|
846
|
+
}
|
|
847
|
+
subs.add(session.id);
|
|
848
|
+
session.subscriptions.add(key);
|
|
849
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
850
|
+
}
|
|
851
|
+
unsubscribe(session, msg) {
|
|
852
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
853
|
+
const uri = params.uri;
|
|
854
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
855
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: '"uri" must be a non-empty string' });
|
|
856
|
+
}
|
|
857
|
+
for (const key of [...session.subscriptions]) {
|
|
858
|
+
if (key.slice(key.indexOf('\u0000') + 1) === uri)
|
|
859
|
+
this.releaseSub(session, key);
|
|
860
|
+
}
|
|
861
|
+
return { jsonrpc: '2.0', id: msg.id, result: {} };
|
|
862
|
+
}
|
|
863
|
+
releaseSub(session, key) {
|
|
864
|
+
session.subscriptions.delete(key);
|
|
865
|
+
const subs = this.upstreamSubs.get(key);
|
|
866
|
+
if (!subs)
|
|
867
|
+
return;
|
|
868
|
+
subs.delete(session.id);
|
|
869
|
+
if (subs.size === 0)
|
|
870
|
+
this.dropUpstreamSub(key);
|
|
871
|
+
}
|
|
872
|
+
dropUpstreamSub(key) {
|
|
873
|
+
this.upstreamSubs.delete(key);
|
|
874
|
+
const i = key.indexOf('\u0000');
|
|
875
|
+
const serverId = key.slice(0, i);
|
|
876
|
+
if (!this.deps.proxy.isConnected(serverId))
|
|
877
|
+
return;
|
|
878
|
+
void this.deps.proxy.request(serverId, 'resources/unsubscribe', { uri: key.slice(i + 1) }, 10_000);
|
|
879
|
+
}
|
|
880
|
+
/** Number of upstream subscriptions (for tests / monitoring). */
|
|
881
|
+
subscriptionCount() {
|
|
882
|
+
return this.upstreamSubs.size;
|
|
883
|
+
}
|
|
884
|
+
// ─── Upstream notifications ─────────────────────────────────────────────────
|
|
885
|
+
handleUpstreamConnected(serverId) {
|
|
886
|
+
// Subscriptions and the log level do not survive a reconnect: restore them.
|
|
887
|
+
for (const key of this.upstreamSubs.keys()) {
|
|
888
|
+
const i = key.indexOf('\u0000');
|
|
889
|
+
if (key.slice(0, i) !== serverId)
|
|
890
|
+
continue;
|
|
891
|
+
void this.deps.proxy.request(serverId, 'resources/subscribe', { uri: key.slice(i + 1) }, 10_000);
|
|
892
|
+
}
|
|
893
|
+
this.upstreamLogLevel.delete(serverId);
|
|
894
|
+
this.pushUpstreamLogLevel(serverId);
|
|
895
|
+
}
|
|
896
|
+
handleUpstreamNotification(serverId, msg) {
|
|
897
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
898
|
+
if (msg.method === 'notifications/resources/updated' && typeof params.uri === 'string') {
|
|
899
|
+
const subs = this.upstreamSubs.get(`${serverId}\u0000${params.uri}`);
|
|
900
|
+
for (const sid of subs ?? []) {
|
|
901
|
+
const s = this.sessions.get(sid);
|
|
902
|
+
if (s && isServerInScope(s.auth.scope, serverId)) {
|
|
903
|
+
this.send(s, { jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: params.uri } });
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (msg.method === 'notifications/message') {
|
|
909
|
+
const lvl = levelIndex(params.level);
|
|
910
|
+
if (lvl < 0)
|
|
911
|
+
return;
|
|
912
|
+
const name = typeof params.logger === 'string' && params.logger ? `${serverId}/${params.logger}` : serverId;
|
|
913
|
+
for (const s of this.sessions.values()) {
|
|
914
|
+
const min = levelIndex(s.logLevel);
|
|
915
|
+
if (min < 0 || lvl < min || !isServerInScope(s.auth.scope, serverId))
|
|
916
|
+
continue;
|
|
917
|
+
this.send(s, { jsonrpc: '2.0', method: 'notifications/message', params: { level: params.level, logger: name, data: params.data } });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
609
921
|
// ─── Notifications / housekeeping ───────────────────────────────────────────
|
|
610
922
|
scheduleListChanged() {
|
|
611
923
|
if (this.closed)
|
|
@@ -649,12 +961,25 @@ export class McpEndpoint {
|
|
|
649
961
|
}
|
|
650
962
|
}
|
|
651
963
|
/** Send a message on the session's most recent stream. */
|
|
964
|
+
/**
|
|
965
|
+
* Send a message on the session's most recent stream. Every event gets a
|
|
966
|
+
* session-wide id and is kept in a bounded buffer (`mcp.eventBufferSize`),
|
|
967
|
+
* so a client that reconnects with `Last-Event-ID` receives what it missed —
|
|
968
|
+
* including events emitted while no stream was open.
|
|
969
|
+
*/
|
|
652
970
|
send(session, msg) {
|
|
971
|
+
const id = ++session.eventSeq;
|
|
972
|
+
const data = JSON.stringify(msg);
|
|
973
|
+
const cap = this.cfg.eventBufferSize;
|
|
974
|
+
if (cap > 0) {
|
|
975
|
+
session.eventLog.push({ id, data });
|
|
976
|
+
if (session.eventLog.length > cap)
|
|
977
|
+
session.eventLog.splice(0, session.eventLog.length - cap);
|
|
978
|
+
}
|
|
653
979
|
const stream = session.streams[session.streams.length - 1];
|
|
654
980
|
if (!stream)
|
|
655
981
|
return false;
|
|
656
|
-
|
|
657
|
-
stream.write(`id: ${session.eventSeq}\nevent: message\ndata: ${JSON.stringify(msg)}\n\n`);
|
|
982
|
+
stream.write(`id: ${id}\nevent: message\ndata: ${data}\n\n`);
|
|
658
983
|
return true;
|
|
659
984
|
}
|
|
660
985
|
keepalive() {
|
|
@@ -687,6 +1012,8 @@ export class McpEndpoint {
|
|
|
687
1012
|
}
|
|
688
1013
|
endSession(session) {
|
|
689
1014
|
this.sessions.delete(session.id);
|
|
1015
|
+
for (const key of [...session.subscriptions])
|
|
1016
|
+
this.releaseSub(session, key);
|
|
690
1017
|
for (const c of session.inflight.values())
|
|
691
1018
|
c.abort();
|
|
692
1019
|
session.inflight.clear();
|