@winstonsayno/mcp-gateway 1.0.1 → 1.2.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 +96 -0
- package/README.md +46 -10
- package/dashboard/README.md +64 -15
- package/dashboard/index.html +1568 -419
- package/dist/auth/middleware.d.ts +29 -0
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +142 -18
- package/dist/auth/middleware.js.map +1 -1
- 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 +110 -2
- 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 +86 -4
- package/dist/gateway/api.js.map +1 -1
- package/dist/gateway/index.d.ts +10 -1
- package/dist/gateway/index.d.ts.map +1 -1
- package/dist/gateway/index.js +82 -5
- package/dist/gateway/index.js.map +1 -1
- package/dist/gateway/live.d.ts +85 -0
- package/dist/gateway/live.d.ts.map +1 -0
- package/dist/gateway/live.js +206 -0
- package/dist/gateway/live.js.map +1 -0
- 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 +46 -1
- package/dist/mcp/endpoint.d.ts.map +1 -1
- package/dist/mcp/endpoint.js +312 -10
- 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 +73 -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 +81 -2
- 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];
|
|
@@ -84,12 +96,23 @@ export class McpEndpoint {
|
|
|
84
96
|
notifyTimer;
|
|
85
97
|
closed = false;
|
|
86
98
|
onRegistryChange = () => this.scheduleListChanged();
|
|
99
|
+
/** Upstream resource subscriptions: `<serverId>\0<uri>` → subscribed session ids. */
|
|
100
|
+
upstreamSubs = new Map();
|
|
101
|
+
/** Last `logging/setLevel` sent to each upstream server. */
|
|
102
|
+
upstreamLogLevel = new Map();
|
|
103
|
+
onUpstreamNotification = (serverId, msg) => this.handleUpstreamNotification(serverId, msg);
|
|
104
|
+
onUpstreamConnected = (serverId) => this.handleUpstreamConnected(serverId);
|
|
105
|
+
onUpstreamDisconnected = (serverId) => this.upstreamLogLevel.delete(serverId);
|
|
106
|
+
jsonParser;
|
|
87
107
|
constructor(config, deps) {
|
|
88
108
|
this.deps = deps;
|
|
89
109
|
this.cfg = { ...DEFAULT_MCP_CONFIG, ...stripUndefined(config ?? {}) };
|
|
90
110
|
deps.registry.on('tools-updated', this.onRegistryChange);
|
|
91
111
|
deps.registry.on('unregistered', this.onRegistryChange);
|
|
92
112
|
deps.registry.on('catalog-updated', this.onRegistryChange);
|
|
113
|
+
deps.proxy.on('notification', this.onUpstreamNotification);
|
|
114
|
+
deps.proxy.on('connected', this.onUpstreamConnected);
|
|
115
|
+
deps.proxy.on('disconnected', this.onUpstreamDisconnected);
|
|
93
116
|
this.sweepTimer = setInterval(() => this.sweep(), 60_000);
|
|
94
117
|
this.sweepTimer.unref();
|
|
95
118
|
this.keepaliveTimer = setInterval(() => this.keepalive(), SSE_KEEPALIVE_MS);
|
|
@@ -112,7 +135,13 @@ export class McpEndpoint {
|
|
|
112
135
|
/** Express router serving POST / GET / DELETE on the configured path. */
|
|
113
136
|
router() {
|
|
114
137
|
const r = express.Router();
|
|
115
|
-
const json =
|
|
138
|
+
const json = (req, res, next) => {
|
|
139
|
+
const limit = this.deps.maxBodyBytes?.() ?? 10 * 1024 * 1024;
|
|
140
|
+
if (this.jsonParser?.limit !== limit) {
|
|
141
|
+
this.jsonParser = { limit, mw: express.json({ limit, type: ['application/json', 'application/*+json'] }) };
|
|
142
|
+
}
|
|
143
|
+
this.jsonParser.mw(req, res, next);
|
|
144
|
+
};
|
|
116
145
|
const path = this.cfg.path;
|
|
117
146
|
const guard = (req, res, next) => this.checkOrigin(req, res, next);
|
|
118
147
|
r.post(path, guard, this.deps.authenticate, json, (req, res, next) => {
|
|
@@ -150,6 +179,9 @@ export class McpEndpoint {
|
|
|
150
179
|
this.deps.registry.off('tools-updated', this.onRegistryChange);
|
|
151
180
|
this.deps.registry.off('unregistered', this.onRegistryChange);
|
|
152
181
|
this.deps.registry.off('catalog-updated', this.onRegistryChange);
|
|
182
|
+
this.deps.proxy.off('notification', this.onUpstreamNotification);
|
|
183
|
+
this.deps.proxy.off('connected', this.onUpstreamConnected);
|
|
184
|
+
this.deps.proxy.off('disconnected', this.onUpstreamDisconnected);
|
|
153
185
|
for (const s of [...this.sessions.values()])
|
|
154
186
|
this.endSession(s);
|
|
155
187
|
}
|
|
@@ -171,8 +203,15 @@ export class McpEndpoint {
|
|
|
171
203
|
// requests; non-browser clients usually send none.
|
|
172
204
|
const origin = req.headers.origin;
|
|
173
205
|
if (origin) {
|
|
174
|
-
const
|
|
175
|
-
|
|
206
|
+
const configured = this.cfg.allowedOrigins ?? this.deps.corsOrigins();
|
|
207
|
+
const strict = this.deps.strictOrigins?.() === true;
|
|
208
|
+
const ok = strict
|
|
209
|
+
? isSameOrigin(origin, req.headers.host) ||
|
|
210
|
+
isLoopbackOrigin(origin) ||
|
|
211
|
+
originAllowed((configured ?? []).filter((o) => o !== '*'), origin)
|
|
212
|
+
: originAllowed(configured ?? ['*'], origin);
|
|
213
|
+
if (!ok) {
|
|
214
|
+
logger.warn(`Rejected /mcp request from Origin ${origin}`);
|
|
176
215
|
res.status(403).json(rpcError(null, { code: JSONRPC_INVALID_REQUEST, message: 'Origin not allowed' }));
|
|
177
216
|
return;
|
|
178
217
|
}
|
|
@@ -231,12 +270,15 @@ export class McpEndpoint {
|
|
|
231
270
|
for (const c of controllers)
|
|
232
271
|
c.abort();
|
|
233
272
|
});
|
|
273
|
+
// Single requests from clients accepting SSE may be upgraded to an SSE
|
|
274
|
+
// reply (progress notifications before the result).
|
|
275
|
+
const stream = !batch && acceptsEventStream(req) ? this.replyStream(res, session) : undefined;
|
|
234
276
|
const replies = await Promise.all(requests.map((m) => {
|
|
235
277
|
const ctrl = new AbortController();
|
|
236
278
|
controllers.push(ctrl);
|
|
237
279
|
const key = idKey(m.id);
|
|
238
280
|
session.inflight.set(key, ctrl);
|
|
239
|
-
return this.handleRequest(session, req, res, m, ctrl.signal, !batch)
|
|
281
|
+
return this.handleRequest(session, req, res, m, ctrl.signal, !batch, stream)
|
|
240
282
|
.catch((err) => {
|
|
241
283
|
logger.error(`MCP ${m.method} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
242
284
|
return rpcError(m.id, { code: JSONRPC_INTERNAL_ERROR, message: 'Internal error' });
|
|
@@ -246,10 +288,39 @@ export class McpEndpoint {
|
|
|
246
288
|
session.inflight.delete(key);
|
|
247
289
|
});
|
|
248
290
|
}));
|
|
291
|
+
if (stream?.started) {
|
|
292
|
+
if (!res.writableEnded) {
|
|
293
|
+
stream.send(replies[0]);
|
|
294
|
+
res.end();
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
249
298
|
if (res.headersSent || res.destroyed)
|
|
250
299
|
return;
|
|
251
300
|
res.status(200).json(batch ? replies : replies[0]);
|
|
252
301
|
}
|
|
302
|
+
replyStream(res, session) {
|
|
303
|
+
const stream = {
|
|
304
|
+
started: false,
|
|
305
|
+
send: (msg) => {
|
|
306
|
+
if (res.writableEnded || res.destroyed)
|
|
307
|
+
return;
|
|
308
|
+
if (!stream.started) {
|
|
309
|
+
stream.started = true;
|
|
310
|
+
res.status(200).set({
|
|
311
|
+
'Content-Type': 'text/event-stream',
|
|
312
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
313
|
+
Connection: 'keep-alive',
|
|
314
|
+
'X-Accel-Buffering': 'no',
|
|
315
|
+
'Mcp-Session-Id': session.id,
|
|
316
|
+
});
|
|
317
|
+
res.flushHeaders();
|
|
318
|
+
}
|
|
319
|
+
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
return stream;
|
|
323
|
+
}
|
|
253
324
|
initialize(req, res, msg) {
|
|
254
325
|
const params = isObject(msg.params) ? msg.params : {};
|
|
255
326
|
const requested = typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined;
|
|
@@ -275,6 +346,7 @@ export class McpEndpoint {
|
|
|
275
346
|
inflight: new Map(),
|
|
276
347
|
eventSeq: 0,
|
|
277
348
|
auth: identityOf(req),
|
|
349
|
+
subscriptions: new Set(),
|
|
278
350
|
};
|
|
279
351
|
session.toolsFingerprint = this.fingerprint(this.toolIndex(session.auth));
|
|
280
352
|
session.resourcesFingerprint = this.resourcesFingerprint(session.auth);
|
|
@@ -286,8 +358,10 @@ export class McpEndpoint {
|
|
|
286
358
|
protocolVersion,
|
|
287
359
|
capabilities: {
|
|
288
360
|
tools: { listChanged: true },
|
|
289
|
-
resources: { listChanged: true },
|
|
361
|
+
resources: { listChanged: true, subscribe: true },
|
|
290
362
|
prompts: { listChanged: true },
|
|
363
|
+
logging: {},
|
|
364
|
+
completions: {},
|
|
291
365
|
},
|
|
292
366
|
serverInfo: { name: 'mcp-gateway', title: 'mcp-gateway', version: VERSION },
|
|
293
367
|
};
|
|
@@ -360,10 +434,18 @@ export class McpEndpoint {
|
|
|
360
434
|
}
|
|
361
435
|
// notifications/initialized and others need no action.
|
|
362
436
|
}
|
|
363
|
-
async handleRequest(session, req, res, msg, signal, single) {
|
|
437
|
+
async handleRequest(session, req, res, msg, signal, single, stream) {
|
|
364
438
|
const id = msg.id;
|
|
365
439
|
const ok = (result) => ({ jsonrpc: '2.0', id, result });
|
|
366
440
|
switch (msg.method) {
|
|
441
|
+
case 'logging/setLevel':
|
|
442
|
+
return this.setLogLevel(session, msg);
|
|
443
|
+
case 'completion/complete':
|
|
444
|
+
return this.complete(session, msg, signal);
|
|
445
|
+
case 'resources/subscribe':
|
|
446
|
+
return this.subscribe(session, msg);
|
|
447
|
+
case 'resources/unsubscribe':
|
|
448
|
+
return this.unsubscribe(session, msg);
|
|
367
449
|
case 'ping':
|
|
368
450
|
return ok({});
|
|
369
451
|
case 'initialize':
|
|
@@ -371,7 +453,7 @@ export class McpEndpoint {
|
|
|
371
453
|
case 'tools/list':
|
|
372
454
|
return this.listTools(session, msg);
|
|
373
455
|
case 'tools/call':
|
|
374
|
-
return this.callTool(session, req, res, msg, signal, single);
|
|
456
|
+
return this.callTool(session, req, res, msg, signal, single, stream);
|
|
375
457
|
case 'resources/list': {
|
|
376
458
|
const list = this.resources(session.auth);
|
|
377
459
|
if (isFirstPage(msg))
|
|
@@ -488,6 +570,9 @@ export class McpEndpoint {
|
|
|
488
570
|
}
|
|
489
571
|
if (!isObject(args))
|
|
490
572
|
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: '"arguments" must be an object' });
|
|
573
|
+
const tooLarge = this.argumentsTooLarge(msg.id, args);
|
|
574
|
+
if (tooLarge)
|
|
575
|
+
return tooLarge;
|
|
491
576
|
const prompt = this.promptIndex(session.auth).byName.get(name);
|
|
492
577
|
if (!prompt)
|
|
493
578
|
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: `Unknown prompt: ${name}` });
|
|
@@ -536,7 +621,7 @@ export class McpEndpoint {
|
|
|
536
621
|
result.nextCursor = encodeCursor(next);
|
|
537
622
|
return { jsonrpc: '2.0', id: msg.id, result };
|
|
538
623
|
}
|
|
539
|
-
async callTool(session, req, res, msg, signal, single) {
|
|
624
|
+
async callTool(session, req, res, msg, signal, single, stream) {
|
|
540
625
|
const id = msg.id;
|
|
541
626
|
const params = isObject(msg.params) ? msg.params : {};
|
|
542
627
|
const name = params.name;
|
|
@@ -547,6 +632,14 @@ export class McpEndpoint {
|
|
|
547
632
|
if (!isObject(args)) {
|
|
548
633
|
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"arguments" must be an object' });
|
|
549
634
|
}
|
|
635
|
+
const tooLarge = this.argumentsTooLarge(id, args);
|
|
636
|
+
if (tooLarge)
|
|
637
|
+
return tooLarge;
|
|
638
|
+
const meta = isObject(params._meta) ? params._meta : {};
|
|
639
|
+
const progressToken = typeof meta.progressToken === 'string' || typeof meta.progressToken === 'number' ? meta.progressToken : undefined;
|
|
640
|
+
const onProgress = progressToken !== undefined && stream
|
|
641
|
+
? (u) => stream.send({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken, ...u } })
|
|
642
|
+
: undefined;
|
|
550
643
|
const tool = this.toolIndex(session.auth).byName.get(name);
|
|
551
644
|
if (!tool) {
|
|
552
645
|
// A tool that exists but is outside the client's scope is refused with
|
|
@@ -578,7 +671,7 @@ export class McpEndpoint {
|
|
|
578
671
|
const status = this.deps.registry.getHealth(serverId)?.status;
|
|
579
672
|
return toolError(`Server "${serverId}" is not connected${status ? ` (${status})` : ''}; try again later.`);
|
|
580
673
|
}
|
|
581
|
-
const result = await this.deps.proxy.callTool(serverId, tool.name, args, server.timeout, { signal });
|
|
674
|
+
const result = await this.deps.proxy.callTool(serverId, tool.name, args, server.timeout, { signal, onProgress });
|
|
582
675
|
this.deps.metrics.record({
|
|
583
676
|
serverId,
|
|
584
677
|
toolName: tool.name,
|
|
@@ -606,6 +699,213 @@ export class McpEndpoint {
|
|
|
606
699
|
return rpcError(id, err);
|
|
607
700
|
}
|
|
608
701
|
}
|
|
702
|
+
argumentsTooLarge(id, args) {
|
|
703
|
+
const limit = this.deps.maxArgumentsBytes?.() ?? 0;
|
|
704
|
+
if (!limit || Buffer.byteLength(JSON.stringify(args ?? {}), 'utf8') <= limit)
|
|
705
|
+
return undefined;
|
|
706
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: `"arguments" exceed the gateway limit of ${limit} bytes` });
|
|
707
|
+
}
|
|
708
|
+
// ─── Logging ────────────────────────────────────────────────────────────────
|
|
709
|
+
setLogLevel(session, msg) {
|
|
710
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
711
|
+
if (levelIndex(params.level) < 0) {
|
|
712
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: `"level" must be one of: ${LOG_LEVELS.join(', ')}` });
|
|
713
|
+
}
|
|
714
|
+
session.logLevel = params.level;
|
|
715
|
+
this.pushUpstreamLogLevel();
|
|
716
|
+
return { jsonrpc: '2.0', id: msg.id, result: {} };
|
|
717
|
+
}
|
|
718
|
+
/** Most verbose level any session asked for (undefined when none did). */
|
|
719
|
+
wantedLogLevel() {
|
|
720
|
+
let min = -1;
|
|
721
|
+
for (const s of this.sessions.values()) {
|
|
722
|
+
const i = levelIndex(s.logLevel);
|
|
723
|
+
if (i >= 0 && (min < 0 || i < min))
|
|
724
|
+
min = i;
|
|
725
|
+
}
|
|
726
|
+
return min >= 0 ? LOG_LEVELS[min] : undefined;
|
|
727
|
+
}
|
|
728
|
+
/** Send `logging/setLevel` to upstream servers with the `logging` capability when the wanted level changed. */
|
|
729
|
+
pushUpstreamLogLevel(only) {
|
|
730
|
+
const level = this.wantedLogLevel();
|
|
731
|
+
if (!level)
|
|
732
|
+
return;
|
|
733
|
+
const ids = only ? [only] : this.deps.registry.getEnabledServers().map((s) => s.id);
|
|
734
|
+
for (const serverId of ids) {
|
|
735
|
+
if (!this.deps.proxy.hasCapability(serverId, 'logging'))
|
|
736
|
+
continue;
|
|
737
|
+
if (this.upstreamLogLevel.get(serverId) === level)
|
|
738
|
+
continue;
|
|
739
|
+
this.upstreamLogLevel.set(serverId, level);
|
|
740
|
+
void this.deps.proxy.request(serverId, 'logging/setLevel', { level }, 5_000).then((r) => {
|
|
741
|
+
if (!r.success)
|
|
742
|
+
logger.debug(`[${serverId}] logging/setLevel failed: ${r.error?.message}`);
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// ─── Completion ─────────────────────────────────────────────────────────────
|
|
747
|
+
async complete(session, msg, signal) {
|
|
748
|
+
const id = msg.id;
|
|
749
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
750
|
+
const ref = isObject(params.ref) ? params.ref : undefined;
|
|
751
|
+
const argument = isObject(params.argument) ? params.argument : undefined;
|
|
752
|
+
if (!ref || !argument || typeof argument.name !== 'string' || typeof argument.value !== 'string') {
|
|
753
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"ref" and "argument" { name, value } are required' });
|
|
754
|
+
}
|
|
755
|
+
const tooLarge = this.argumentsTooLarge(id, params.context ?? {});
|
|
756
|
+
if (tooLarge)
|
|
757
|
+
return tooLarge;
|
|
758
|
+
let serverId;
|
|
759
|
+
let upstreamRef = ref;
|
|
760
|
+
if (ref.type === 'ref/prompt' && typeof ref.name === 'string') {
|
|
761
|
+
const prompt = this.promptIndex(session.auth).byName.get(ref.name);
|
|
762
|
+
if (!prompt)
|
|
763
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: `Unknown prompt: ${ref.name}` });
|
|
764
|
+
serverId = prompt.serverId;
|
|
765
|
+
upstreamRef = { ...ref, name: prompt.name };
|
|
766
|
+
}
|
|
767
|
+
else if (ref.type === 'ref/resource' && typeof ref.uri === 'string') {
|
|
768
|
+
const uri = ref.uri;
|
|
769
|
+
const template = this.templates(session.auth).find((t) => t.uriTemplate === uri);
|
|
770
|
+
serverId =
|
|
771
|
+
template?.serverId ??
|
|
772
|
+
this.resources(session.auth).find((r) => r.uri === uri)?.serverId ??
|
|
773
|
+
this.templates(session.auth).find((t) => matchesUriTemplate(t.uriTemplate, uri))?.serverId;
|
|
774
|
+
if (!serverId)
|
|
775
|
+
return rpcError(id, { code: -32002, message: 'Resource not found', data: { uri } });
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"ref.type" must be "ref/prompt" or "ref/resource"' });
|
|
779
|
+
}
|
|
780
|
+
const empty = { completion: { values: [], hasMore: false } };
|
|
781
|
+
if (!this.deps.proxy.isConnected(serverId) || !this.deps.proxy.hasCapability(serverId, 'completions')) {
|
|
782
|
+
return { jsonrpc: '2.0', id, result: empty };
|
|
783
|
+
}
|
|
784
|
+
const upstreamParams = { ref: upstreamRef, argument };
|
|
785
|
+
if (isObject(params.context))
|
|
786
|
+
upstreamParams.context = params.context;
|
|
787
|
+
const server = this.deps.registry.getServer(serverId);
|
|
788
|
+
const r = await this.deps.proxy.request(serverId, 'completion/complete', upstreamParams, server?.timeout, { signal });
|
|
789
|
+
if (r.success)
|
|
790
|
+
return { jsonrpc: '2.0', id, result: r.result ?? empty };
|
|
791
|
+
if (r.error?.code === ERR_CANCELLED)
|
|
792
|
+
return rpcError(id, { code: ERR_CANCELLED, message: 'Request cancelled' });
|
|
793
|
+
return rpcError(id, r.error ?? { code: JSONRPC_INTERNAL_ERROR, message: 'Unknown error' });
|
|
794
|
+
}
|
|
795
|
+
// ─── Resource subscriptions ─────────────────────────────────────────────────
|
|
796
|
+
routeUri(session, uri) {
|
|
797
|
+
const scope = session.auth.scope;
|
|
798
|
+
return routeResource(uri, this.resources(session.auth), this.templates(session.auth), this.deps.registry
|
|
799
|
+
.getEnabledServers()
|
|
800
|
+
.map((s) => s.id)
|
|
801
|
+
.filter((sid) => isServerInScope(scope, sid) && this.deps.proxy.hasCapability(sid, 'resources')));
|
|
802
|
+
}
|
|
803
|
+
async subscribe(session, msg) {
|
|
804
|
+
const id = msg.id;
|
|
805
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
806
|
+
const uri = params.uri;
|
|
807
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
808
|
+
return rpcError(id, { code: JSONRPC_INVALID_PARAMS, message: '"uri" must be a non-empty string' });
|
|
809
|
+
}
|
|
810
|
+
const serverId = this.routeUri(session, uri);
|
|
811
|
+
if (!serverId)
|
|
812
|
+
return rpcError(id, { code: -32002, message: 'Resource not found', data: { uri } });
|
|
813
|
+
const caps = this.deps.proxy.getSessionInfo(serverId)?.capabilities;
|
|
814
|
+
const resCaps = isObject(caps?.resources) ? caps.resources : {};
|
|
815
|
+
if (resCaps.subscribe !== true) {
|
|
816
|
+
return rpcError(id, { code: JSONRPC_METHOD_NOT_FOUND, message: `Server "${serverId}" does not support resource subscriptions` });
|
|
817
|
+
}
|
|
818
|
+
const key = `${serverId}\u0000${uri}`;
|
|
819
|
+
if (session.subscriptions.has(key))
|
|
820
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
821
|
+
let subs = this.upstreamSubs.get(key);
|
|
822
|
+
if (!subs || subs.size === 0) {
|
|
823
|
+
const r = await this.deps.proxy.request(serverId, 'resources/subscribe', { uri }, 10_000);
|
|
824
|
+
if (!r.success)
|
|
825
|
+
return rpcError(id, r.error ?? { code: JSONRPC_INTERNAL_ERROR, message: 'Subscribe failed' });
|
|
826
|
+
subs = this.upstreamSubs.get(key) ?? new Set();
|
|
827
|
+
this.upstreamSubs.set(key, subs);
|
|
828
|
+
}
|
|
829
|
+
if (!this.sessions.has(session.id)) {
|
|
830
|
+
// The session ended while subscribing.
|
|
831
|
+
if (subs.size === 0)
|
|
832
|
+
this.dropUpstreamSub(key);
|
|
833
|
+
return rpcError(id, { code: -32001, message: 'Session not found' });
|
|
834
|
+
}
|
|
835
|
+
subs.add(session.id);
|
|
836
|
+
session.subscriptions.add(key);
|
|
837
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
838
|
+
}
|
|
839
|
+
unsubscribe(session, msg) {
|
|
840
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
841
|
+
const uri = params.uri;
|
|
842
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
843
|
+
return rpcError(msg.id, { code: JSONRPC_INVALID_PARAMS, message: '"uri" must be a non-empty string' });
|
|
844
|
+
}
|
|
845
|
+
for (const key of [...session.subscriptions]) {
|
|
846
|
+
if (key.slice(key.indexOf('\u0000') + 1) === uri)
|
|
847
|
+
this.releaseSub(session, key);
|
|
848
|
+
}
|
|
849
|
+
return { jsonrpc: '2.0', id: msg.id, result: {} };
|
|
850
|
+
}
|
|
851
|
+
releaseSub(session, key) {
|
|
852
|
+
session.subscriptions.delete(key);
|
|
853
|
+
const subs = this.upstreamSubs.get(key);
|
|
854
|
+
if (!subs)
|
|
855
|
+
return;
|
|
856
|
+
subs.delete(session.id);
|
|
857
|
+
if (subs.size === 0)
|
|
858
|
+
this.dropUpstreamSub(key);
|
|
859
|
+
}
|
|
860
|
+
dropUpstreamSub(key) {
|
|
861
|
+
this.upstreamSubs.delete(key);
|
|
862
|
+
const i = key.indexOf('\u0000');
|
|
863
|
+
const serverId = key.slice(0, i);
|
|
864
|
+
if (!this.deps.proxy.isConnected(serverId))
|
|
865
|
+
return;
|
|
866
|
+
void this.deps.proxy.request(serverId, 'resources/unsubscribe', { uri: key.slice(i + 1) }, 10_000);
|
|
867
|
+
}
|
|
868
|
+
/** Number of upstream subscriptions (for tests / monitoring). */
|
|
869
|
+
subscriptionCount() {
|
|
870
|
+
return this.upstreamSubs.size;
|
|
871
|
+
}
|
|
872
|
+
// ─── Upstream notifications ─────────────────────────────────────────────────
|
|
873
|
+
handleUpstreamConnected(serverId) {
|
|
874
|
+
// Subscriptions and the log level do not survive a reconnect: restore them.
|
|
875
|
+
for (const key of this.upstreamSubs.keys()) {
|
|
876
|
+
const i = key.indexOf('\u0000');
|
|
877
|
+
if (key.slice(0, i) !== serverId)
|
|
878
|
+
continue;
|
|
879
|
+
void this.deps.proxy.request(serverId, 'resources/subscribe', { uri: key.slice(i + 1) }, 10_000);
|
|
880
|
+
}
|
|
881
|
+
this.upstreamLogLevel.delete(serverId);
|
|
882
|
+
this.pushUpstreamLogLevel(serverId);
|
|
883
|
+
}
|
|
884
|
+
handleUpstreamNotification(serverId, msg) {
|
|
885
|
+
const params = isObject(msg.params) ? msg.params : {};
|
|
886
|
+
if (msg.method === 'notifications/resources/updated' && typeof params.uri === 'string') {
|
|
887
|
+
const subs = this.upstreamSubs.get(`${serverId}\u0000${params.uri}`);
|
|
888
|
+
for (const sid of subs ?? []) {
|
|
889
|
+
const s = this.sessions.get(sid);
|
|
890
|
+
if (s && isServerInScope(s.auth.scope, serverId)) {
|
|
891
|
+
this.send(s, { jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri: params.uri } });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (msg.method === 'notifications/message') {
|
|
897
|
+
const lvl = levelIndex(params.level);
|
|
898
|
+
if (lvl < 0)
|
|
899
|
+
return;
|
|
900
|
+
const name = typeof params.logger === 'string' && params.logger ? `${serverId}/${params.logger}` : serverId;
|
|
901
|
+
for (const s of this.sessions.values()) {
|
|
902
|
+
const min = levelIndex(s.logLevel);
|
|
903
|
+
if (min < 0 || lvl < min || !isServerInScope(s.auth.scope, serverId))
|
|
904
|
+
continue;
|
|
905
|
+
this.send(s, { jsonrpc: '2.0', method: 'notifications/message', params: { level: params.level, logger: name, data: params.data } });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
609
909
|
// ─── Notifications / housekeeping ───────────────────────────────────────────
|
|
610
910
|
scheduleListChanged() {
|
|
611
911
|
if (this.closed)
|
|
@@ -687,6 +987,8 @@ export class McpEndpoint {
|
|
|
687
987
|
}
|
|
688
988
|
endSession(session) {
|
|
689
989
|
this.sessions.delete(session.id);
|
|
990
|
+
for (const key of [...session.subscriptions])
|
|
991
|
+
this.releaseSub(session, key);
|
|
690
992
|
for (const c of session.inflight.values())
|
|
691
993
|
c.abort();
|
|
692
994
|
session.inflight.clear();
|