@winstonsayno/mcp-gateway 1.1.0 → 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.
Files changed (67) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +32 -6
  3. package/dashboard/README.md +4 -0
  4. package/dashboard/index.html +42 -3
  5. package/dist/auth/middleware.d.ts +29 -0
  6. package/dist/auth/middleware.d.ts.map +1 -1
  7. package/dist/auth/middleware.js +142 -18
  8. package/dist/auth/middleware.js.map +1 -1
  9. package/dist/cli.js +54 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config/loader.d.ts.map +1 -1
  12. package/dist/config/loader.js +110 -2
  13. package/dist/config/loader.js.map +1 -1
  14. package/dist/gateway/api.d.ts +5 -0
  15. package/dist/gateway/api.d.ts.map +1 -1
  16. package/dist/gateway/api.js +86 -4
  17. package/dist/gateway/api.js.map +1 -1
  18. package/dist/gateway/index.d.ts +9 -1
  19. package/dist/gateway/index.d.ts.map +1 -1
  20. package/dist/gateway/index.js +76 -5
  21. package/dist/gateway/index.js.map +1 -1
  22. package/dist/index.d.ts +11 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp/catalog.d.ts +1 -1
  27. package/dist/mcp/endpoint.d.ts +46 -1
  28. package/dist/mcp/endpoint.d.ts.map +1 -1
  29. package/dist/mcp/endpoint.js +312 -10
  30. package/dist/mcp/endpoint.js.map +1 -1
  31. package/dist/middleware/error-handler.d.ts +10 -1
  32. package/dist/middleware/error-handler.d.ts.map +1 -1
  33. package/dist/middleware/error-handler.js +17 -3
  34. package/dist/middleware/error-handler.js.map +1 -1
  35. package/dist/monitor/index.d.ts.map +1 -1
  36. package/dist/monitor/index.js +5 -0
  37. package/dist/monitor/index.js.map +1 -1
  38. package/dist/proxy/index.d.ts +15 -0
  39. package/dist/proxy/index.d.ts.map +1 -1
  40. package/dist/proxy/index.js +44 -4
  41. package/dist/proxy/index.js.map +1 -1
  42. package/dist/security/headers.d.ts +23 -0
  43. package/dist/security/headers.d.ts.map +1 -0
  44. package/dist/security/headers.js +71 -0
  45. package/dist/security/headers.js.map +1 -0
  46. package/dist/security/lockout.d.ts +41 -0
  47. package/dist/security/lockout.d.ts.map +1 -0
  48. package/dist/security/lockout.js +117 -0
  49. package/dist/security/lockout.js.map +1 -0
  50. package/dist/security/network.d.ts +31 -0
  51. package/dist/security/network.d.ts.map +1 -0
  52. package/dist/security/network.js +147 -0
  53. package/dist/security/network.js.map +1 -0
  54. package/dist/security/posture.d.ts +18 -0
  55. package/dist/security/posture.d.ts.map +1 -0
  56. package/dist/security/posture.js +73 -0
  57. package/dist/security/posture.js.map +1 -0
  58. package/dist/security/redact.d.ts +34 -0
  59. package/dist/security/redact.d.ts.map +1 -0
  60. package/dist/security/redact.js +115 -0
  61. package/dist/security/redact.js.map +1 -0
  62. package/dist/utils/logger.d.ts.map +1 -1
  63. package/dist/utils/logger.js +4 -2
  64. package/dist/utils/logger.js.map +1 -1
  65. package/dist/utils/types.d.ts +81 -2
  66. package/dist/utils/types.d.ts.map +1 -1
  67. package/package.json +1 -1
@@ -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 = express.json({ limit: '10mb', type: ['application/json', 'application/*+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 allowed = this.cfg.allowedOrigins ?? this.deps.corsOrigins() ?? ['*'];
175
- if (!originAllowed(allowed, origin)) {
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();