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