@tmcp/transport-http 0.8.6 → 0.9.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/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @import { AuthInfo, McpServer } from "tmcp";
3
3
  * @import { OAuth } from "@tmcp/auth";
4
- * @import { StreamSessionManager, InfoSessionManager } from "@tmcp/session-manager";
4
+ * @import { StreamSessionManager, InfoSessionManager, SubscriptionManager } from "@tmcp/session-manager";
5
5
  * @import { OptionalizeSessionManager } from "./type-utils.js"
6
6
  */
7
7
 
@@ -22,17 +22,42 @@
22
22
  * path?: string | null
23
23
  * oauth?: OAuth<"built">
24
24
  * cors?: CorsConfig | boolean,
25
+ * allowedOrigins?: string | string[] | true
25
26
  * sessionManager?: { streams?: StreamSessionManager, info?: OptionalizeSessionManager<InfoSessionManager> }
27
+ * subscriptionManager?: SubscriptionManager
26
28
  * disableSse?: boolean
27
29
  * }} HttpTransportOptions
28
30
  */
29
31
 
32
+ /**
33
+ * @typedef {{ promise: Promise<boolean>, resolve: (registered: boolean) => void }} SubscriptionRegistration
34
+ * @typedef {{ controller?: ReadableStreamDefaultController, state: 'open' | 'cancelled' | 'disconnected', signal?: AbortSignal, subscription?: { id: string | number, registration: SubscriptionRegistration } }} SubscriptionSink
35
+ */
36
+
30
37
  import { AsyncLocalStorage } from 'node:async_hooks';
31
38
  import {
32
39
  InMemoryStreamSessionManager,
33
40
  InMemoryInfoSessionManager,
41
+ InMemorySubscriptionManager,
34
42
  } from '@tmcp/session-manager';
35
43
  import { DEV } from 'esm-env';
44
+ import {
45
+ getPerRequestProtocolVersions,
46
+ McpError,
47
+ UNSUPPORTED_PROTOCOL_VERSION,
48
+ } from 'tmcp';
49
+ import { isPerRequestMethodAllowed } from 'tmcp/method-policy';
50
+ import {
51
+ validate_request_headers,
52
+ validate_tool_parameter_headers,
53
+ } from './request-headers.js';
54
+
55
+ const PER_REQUEST_METADATA_KEYS = [
56
+ 'io.modelcontextprotocol/protocolVersion',
57
+ 'io.modelcontextprotocol/clientCapabilities',
58
+ 'io.modelcontextprotocol/clientInfo',
59
+ 'io.modelcontextprotocol/logLevel',
60
+ ];
36
61
 
37
62
  /**
38
63
  * @template {Record<string, unknown> | undefined} [TCustom=undefined]
@@ -48,7 +73,7 @@ export class HttpTransport {
48
73
  #server;
49
74
 
50
75
  /**
51
- * @type {Required<Omit<HttpTransportOptions, 'oauth' | 'cors' | 'sessionManager' | 'disableSse'>> & { cors?: CorsConfig | boolean, sessionManager: SessionManager, disableSse?: boolean }}
76
+ * @type {Required<Omit<HttpTransportOptions, 'oauth' | 'cors' | 'allowedOrigins' | 'sessionManager' | 'subscriptionManager' | 'disableSse'>> & { cors?: CorsConfig | boolean, allowedOrigins?: string | string[] | true, sessionManager: SessionManager, disableSse?: boolean }}
52
77
  */
53
78
  #options;
54
79
 
@@ -58,7 +83,7 @@ export class HttpTransport {
58
83
  #path;
59
84
 
60
85
  /**
61
- * @type {AsyncLocalStorage<ReadableStreamDefaultController | undefined>}
86
+ * @type {AsyncLocalStorage<SubscriptionSink | undefined>}
62
87
  */
63
88
  #controller_storage = new AsyncLocalStorage();
64
89
 
@@ -74,6 +99,15 @@ export class HttpTransport {
74
99
 
75
100
  #text_encoder = new TextEncoder();
76
101
 
102
+ /** @type {SubscriptionManager} */
103
+ #subscription_manager;
104
+
105
+ /** @type {Map<string, Map<string | number, SubscriptionSink>>} */
106
+ #subscription_sinks = new Map();
107
+ /** @type {WeakMap<Response, { id: string | number, origin: string }>} */
108
+ #subscription_responses = new WeakMap();
109
+ #warned_implicit_origin = false;
110
+
77
111
  /**
78
112
  *
79
113
  * @param {McpServer<any, TCustom>} server
@@ -86,7 +120,9 @@ export class HttpTransport {
86
120
  path = '/mcp',
87
121
  oauth,
88
122
  cors,
123
+ allowedOrigins,
89
124
  disableSse,
125
+ subscriptionManager: _subscriptionManager,
90
126
  sessionManager: _sessionManager = {
91
127
  streams: new InMemoryStreamSessionManager(),
92
128
  info: new InMemoryInfoSessionManager(),
@@ -103,6 +139,9 @@ export class HttpTransport {
103
139
  _sessionManager.streams ?? new InMemoryStreamSessionManager(),
104
140
  info: _sessionManager.info ?? new InMemoryInfoSessionManager(),
105
141
  };
142
+ const subscriptionManager = /** @type {SubscriptionManager} */ (
143
+ _subscriptionManager ?? new InMemorySubscriptionManager()
144
+ );
106
145
 
107
146
  if (options?.path === undefined && DEV) {
108
147
  // TODO: remove on 1.0.0 release
@@ -119,10 +158,12 @@ export class HttpTransport {
119
158
  getSessionId,
120
159
  path,
121
160
  cors,
161
+ allowedOrigins,
122
162
  sessionManager,
123
163
  disableSse,
124
164
  };
125
165
  this.#path = path;
166
+ this.#subscription_manager = subscriptionManager;
126
167
 
127
168
  this.#server.on('initialize', ({ capabilities, clientInfo }) => {
128
169
  const sessionId = this.#session_id_storage.getStore();
@@ -159,33 +200,186 @@ export class HttpTransport {
159
200
  this.#options.sessionManager.info.setLogLevel(sessionId, level);
160
201
  });
161
202
 
162
- this.#server.on('broadcast', async ({ request }) => {
163
- let sessions = undefined;
164
- if (request.method === 'notifications/resources/updated') {
165
- sessions =
166
- await this.#options.sessionManager.info.getSubscriptions(
167
- request.params.uri,
203
+ this.#server.on(
204
+ 'broadcast',
205
+ async ({ request, subscriptionOnly: subscription_only }) => {
206
+ void Promise.resolve(
207
+ this.#subscription_manager.send(request),
208
+ ).catch(() => {});
209
+ if (subscription_only) return;
210
+ let sessions = undefined;
211
+ if (request.method === 'notifications/resources/updated') {
212
+ sessions =
213
+ await this.#options.sessionManager.info.getSubscriptions(
214
+ request.params.uri,
215
+ );
216
+ }
217
+ await this.#options.sessionManager.streams.send(
218
+ sessions,
219
+ 'event: message\ndata: ' + JSON.stringify(request) + '\n\n',
220
+ );
221
+ },
222
+ );
223
+
224
+ this.#server.on(
225
+ 'send',
226
+ ({ request, subscriptionId, subscriptionOrigin }) => {
227
+ if (
228
+ subscriptionId !== undefined &&
229
+ subscriptionOrigin !== undefined
230
+ ) {
231
+ const sink = this.#subscription_sinks
232
+ .get(subscriptionOrigin)
233
+ ?.get(subscriptionId);
234
+ if (!sink) return;
235
+ if (
236
+ request.method ===
237
+ 'notifications/subscriptions/acknowledged'
238
+ ) {
239
+ sink.subscription?.registration.resolve(true);
240
+ }
241
+ if (sink.state !== 'open' || !sink.controller) return;
242
+ try {
243
+ sink.controller.enqueue(
244
+ this.#text_encoder.encode(
245
+ 'event: message\ndata: ' +
246
+ JSON.stringify(request) +
247
+ '\n\n',
248
+ ),
249
+ );
250
+ } catch {
251
+ sink.state = 'disconnected';
252
+ void this.#subscription_manager.close(
253
+ subscriptionId,
254
+ subscriptionOrigin,
255
+ 'cancelled',
256
+ );
257
+ }
258
+ return;
259
+ }
260
+ // use the current controller if the request has an id (it means it's a request and not a notification)
261
+ const sink = this.#controller_storage.getStore();
262
+ if (
263
+ !sink?.controller ||
264
+ sink.state !== 'open' ||
265
+ sink.signal?.aborted
266
+ )
267
+ return;
268
+
269
+ try {
270
+ sink.controller.enqueue(
271
+ this.#text_encoder.encode(
272
+ 'event: message\ndata: ' +
273
+ JSON.stringify(request) +
274
+ '\n\n',
275
+ ),
168
276
  );
169
- }
170
- await this.#options.sessionManager.streams.send(
171
- sessions,
172
- 'event: message\ndata: ' + JSON.stringify(request) + '\n\n',
173
- );
174
- });
277
+ } catch {
278
+ // The response stream may have closed before background work sent.
279
+ }
280
+ },
281
+ );
282
+ }
175
283
 
176
- this.#server.on('send', async ({ request }) => {
177
- // use the current controller if the request has an id (it means it's a request and not a notification)
178
- const controller = this.#controller_storage.getStore();
179
- if (!controller) return;
284
+ /**
285
+ * @param {string} origin
286
+ * @param {string | number} id
287
+ * @param {SubscriptionSink} sink
288
+ * @returns {boolean}
289
+ */
290
+ #register_subscription_sink(origin, id, sink) {
291
+ let sinks = this.#subscription_sinks.get(origin);
292
+ if (!sinks) {
293
+ sinks = new Map();
294
+ this.#subscription_sinks.set(origin, sinks);
295
+ }
296
+ if (sinks.has(id)) return false;
297
+ sinks.set(id, sink);
298
+ return true;
299
+ }
180
300
 
181
- controller.enqueue(
182
- this.#text_encoder.encode(
183
- 'event: message\ndata: ' + JSON.stringify(request) + '\n\n',
184
- ),
185
- );
301
+ /**
302
+ * @param {string} origin
303
+ * @param {string | number} id
304
+ * @param {SubscriptionSink} sink
305
+ */
306
+ #delete_subscription_sink(origin, id, sink) {
307
+ const sinks = this.#subscription_sinks.get(origin);
308
+ if (sinks?.get(id) !== sink) return;
309
+ sinks.delete(id);
310
+ if (sinks.size === 0) this.#subscription_sinks.delete(origin);
311
+ }
312
+
313
+ /**
314
+ * Cancel a routed subscription after its manager registration attempt has
315
+ * completed. This closes a listen that races cancellation without allowing
316
+ * a duplicate POST to affect the original sink.
317
+ * @param {string | number} id
318
+ * @param {string} origin
319
+ */
320
+ async #cancel_subscription(id, origin) {
321
+ const sink = this.#subscription_sinks.get(origin)?.get(id);
322
+ if (!sink) return false;
323
+ sink.state = 'cancelled';
324
+ const registered = await sink.subscription?.registration.promise;
325
+ if (!registered) return false;
326
+ this.#delete_subscription_sink(origin, id, sink);
327
+ return this.#subscription_manager.close(id, origin, 'cancelled');
328
+ }
329
+
330
+ /** @param {SubscriptionSink} sink */
331
+ #manager_for_sink(sink) {
332
+ return /** @type {SubscriptionManager} */ ({
333
+ create: (subscription, callbacks) => {
334
+ if (
335
+ this.#subscription_sinks
336
+ .get(subscription.origin)
337
+ ?.get(subscription.id) !== sink
338
+ ) {
339
+ return false;
340
+ }
341
+ return this.#subscription_manager.create(subscription, {
342
+ ...callbacks,
343
+ close: (reason) => {
344
+ if (
345
+ reason === 'cancelled' &&
346
+ sink.state !== 'disconnected'
347
+ ) {
348
+ sink.state = 'cancelled';
349
+ }
350
+ return callbacks.close(reason);
351
+ },
352
+ });
353
+ },
354
+ send: (notification) =>
355
+ this.#subscription_manager.send(notification),
356
+ close: (id, origin, reason) =>
357
+ this.#subscription_manager.close(id, origin, reason),
358
+ closeAll: (origin, reason) =>
359
+ this.#subscription_manager.closeAll(origin, reason),
186
360
  });
187
361
  }
188
362
 
363
+ /** @param {Request} request */
364
+ #allows_origin(request) {
365
+ const origin = request.headers.get('origin');
366
+ if (!origin) return true;
367
+ if (origin === new URL(request.url).origin) return true;
368
+ const allowed = this.#options.allowedOrigins;
369
+ if (allowed === undefined) {
370
+ if (!this.#warned_implicit_origin) {
371
+ console.warn(
372
+ '[tmcp][transport-http] Cross-origin requests are allowed because `allowedOrigins` is not configured. Set it to an explicit origin list, `[]` to reject all cross-origin requests, or `true` to explicitly allow every origin.',
373
+ );
374
+ this.#warned_implicit_origin = true;
375
+ }
376
+ return true;
377
+ }
378
+ if (allowed === true) return true;
379
+ if (typeof allowed === 'string') return allowed === origin;
380
+ return Array.isArray(allowed) && allowed.includes(origin);
381
+ }
382
+
189
383
  /**
190
384
  * Applies CORS headers to a response based on the configuration
191
385
  * @param {Response} response - The response to modify
@@ -273,6 +467,7 @@ export class HttpTransport {
273
467
  * @param {string} session_id
274
468
  */
275
469
  async #handle_delete(session_id) {
470
+ await this.#subscription_manager.closeAll(session_id, 'cancelled');
276
471
  await this.#options.sessionManager.streams.delete(session_id);
277
472
  await this.#options.sessionManager.info.delete(session_id);
278
473
  return new Response(null, {
@@ -283,6 +478,51 @@ export class HttpTransport {
283
478
  });
284
479
  }
285
480
 
481
+ /**
482
+ * @param {number} status
483
+ * @param {string | number | null} id
484
+ * @param {number} code
485
+ * @param {string} message
486
+ * @param {unknown} [data]
487
+ * @param {string} [session_id]
488
+ */
489
+ #json_rpc_error(status, id, code, message, data, session_id) {
490
+ return new Response(
491
+ JSON.stringify({
492
+ jsonrpc: '2.0',
493
+ id,
494
+ error: {
495
+ code,
496
+ message,
497
+ ...(data === undefined ? {} : { data }),
498
+ },
499
+ }),
500
+ {
501
+ status,
502
+ headers: {
503
+ 'Content-Type': 'application/json',
504
+ ...(session_id ? { 'mcp-session-id': session_id } : {}),
505
+ },
506
+ },
507
+ );
508
+ }
509
+
510
+ /**
511
+ * @param {string | undefined} session_id
512
+ * @param {string} data
513
+ * @param {string | number | null} [id]
514
+ */
515
+ #invalid_request(session_id, data, id = null) {
516
+ return this.#json_rpc_error(
517
+ 400,
518
+ id,
519
+ -32600,
520
+ 'Invalid Request',
521
+ data,
522
+ session_id,
523
+ );
524
+ }
525
+
286
526
  /**
287
527
  *
288
528
  * @param {string} session_id
@@ -306,12 +546,12 @@ export class HttpTransport {
306
546
  return new Response(
307
547
  JSON.stringify({
308
548
  jsonrpc: '2.0',
549
+ id: null,
309
550
  error: {
310
551
  code: -32000,
311
552
  message:
312
553
  'Conflict: Only one SSE stream is allowed per session',
313
554
  },
314
- id: null,
315
555
  }),
316
556
  {
317
557
  headers: {
@@ -340,6 +580,7 @@ export class HttpTransport {
340
580
  'Content-Type': 'text/event-stream',
341
581
  'Cache-Control': 'no-cache',
342
582
  Connection: 'keep-alive',
583
+ 'X-Accel-Buffering': 'no',
343
584
  'mcp-session-id': session_id,
344
585
  },
345
586
  status: 200,
@@ -347,149 +588,448 @@ export class HttpTransport {
347
588
  }
348
589
 
349
590
  /**
350
- *
351
- * @param {string} session_id
591
+ * @param {Request} request
592
+ * @param {unknown} [body]
593
+ */
594
+ #is_per_request(request, body) {
595
+ const header_version = request.headers.get('mcp-protocol-version');
596
+ if (
597
+ header_version !== null &&
598
+ getPerRequestProtocolVersions().includes(header_version)
599
+ ) {
600
+ return true;
601
+ }
602
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
603
+ return false;
604
+ }
605
+ const params = /** @type {Record<string, any>} */ (body).params;
606
+ const meta = params?._meta;
607
+ return (
608
+ typeof meta === 'object' &&
609
+ meta !== null &&
610
+ !Array.isArray(meta) &&
611
+ PER_REQUEST_METADATA_KEYS.some((key) => Object.hasOwn(meta, key))
612
+ );
613
+ }
614
+
615
+ /**
616
+ * @param {Record<string, any>} body
617
+ * @param {Request} request
618
+ * @param {string | number | null} id
619
+ */
620
+ async #preflight_per_request(body, request, id) {
621
+ try {
622
+ validate_request_headers(request.headers, body);
623
+ const requested_version =
624
+ body.params?._meta?.['io.modelcontextprotocol/protocolVersion'];
625
+ const supported = getPerRequestProtocolVersions();
626
+ if (!supported.includes(requested_version)) {
627
+ return this.#json_rpc_error(
628
+ 400,
629
+ id,
630
+ UNSUPPORTED_PROTOCOL_VERSION,
631
+ `Unsupported protocol version: ${String(requested_version)}`,
632
+ { supported, requested: requested_version },
633
+ );
634
+ }
635
+ if (
636
+ !isPerRequestMethodAllowed(body.method) ||
637
+ !this.#server.hasMethod(body.method)
638
+ ) {
639
+ return this.#json_rpc_error(
640
+ 404,
641
+ id,
642
+ -32601,
643
+ `Method ${body.method} not found`,
644
+ );
645
+ }
646
+ if (
647
+ body.method === 'tools/call' &&
648
+ typeof body.params?.name === 'string'
649
+ ) {
650
+ const args =
651
+ typeof body.params.arguments === 'object' &&
652
+ body.params.arguments !== null &&
653
+ !Array.isArray(body.params.arguments)
654
+ ? body.params.arguments
655
+ : {};
656
+ await this.#server.validateToolCall(
657
+ body.params.name,
658
+ args,
659
+ (input_schema, tool_args) =>
660
+ validate_tool_parameter_headers(
661
+ request.headers,
662
+ input_schema,
663
+ tool_args,
664
+ ),
665
+ );
666
+ }
667
+ return null;
668
+ } catch (error) {
669
+ if (error instanceof McpError) {
670
+ return this.#json_rpc_error(
671
+ 400,
672
+ id,
673
+ error.code,
674
+ error.message,
675
+ error.data,
676
+ );
677
+ }
678
+ return this.#json_rpc_error(
679
+ 500,
680
+ id,
681
+ -32603,
682
+ /** @type {Error} */ (error).message ?? 'Internal error',
683
+ );
684
+ }
685
+ }
686
+
687
+ /**
352
688
  * @param {Request} request
353
689
  * @param {AuthInfo | null} auth_info
354
690
  * @param {TCustom} [ctx]
355
691
  */
356
- async #handle_post(session_id, request, auth_info, ctx) {
357
- // Check Content-Type header
692
+ async #handle_post(request, auth_info, ctx) {
693
+ const header_is_per_request = this.#is_per_request(request);
358
694
  const content_type = request.headers.get('content-type');
359
695
  if (!content_type || !content_type.includes('application/json')) {
360
- return new Response(
361
- JSON.stringify({
362
- jsonrpc: '2.0',
363
- error: {
364
- code: -32600,
365
- message: 'Invalid Request',
366
- data: 'Content-Type must be application/json',
367
- },
368
- }),
369
- {
370
- status: 415,
371
- headers: {
372
- 'Content-Type': 'application/json',
373
- 'mcp-session-id': session_id,
374
- },
375
- },
696
+ const session_id = header_is_per_request
697
+ ? undefined
698
+ : request.headers.get('mcp-session-id') ||
699
+ this.#options.getSessionId();
700
+ return this.#json_rpc_error(
701
+ 415,
702
+ null,
703
+ -32600,
704
+ 'Invalid Request',
705
+ 'Content-Type must be application/json',
706
+ session_id,
376
707
  );
377
708
  }
378
709
 
710
+ /** @type {unknown} */
711
+ let parsed_body;
379
712
  try {
380
- const body = await request.clone().json();
381
-
382
- /**
383
- * @type {ReadableStreamDefaultController | undefined}
384
- */
385
- let controller;
386
-
387
- // Create a short-lived stream that closes after sending the response
388
- const stream = new ReadableStream({
389
- start(_controller) {
390
- controller = _controller;
391
- },
392
- });
713
+ parsed_body = await request.json();
714
+ } catch (error) {
715
+ const session_id = header_is_per_request
716
+ ? undefined
717
+ : request.headers.get('mcp-session-id') ||
718
+ this.#options.getSessionId();
719
+ return this.#json_rpc_error(
720
+ 400,
721
+ null,
722
+ -32700,
723
+ 'Parse error',
724
+ /** @type {Error} */ (error).message,
725
+ session_id,
726
+ );
727
+ }
393
728
 
394
- const session_id_storage = this.#session_id_storage;
729
+ const per_request = this.#is_per_request(request, parsed_body);
730
+ const session_id = per_request
731
+ ? undefined
732
+ : request.headers.get('mcp-session-id') ||
733
+ this.#options.getSessionId();
734
+ if (
735
+ Array.isArray(parsed_body) ||
736
+ typeof parsed_body !== 'object' ||
737
+ parsed_body === null
738
+ ) {
739
+ return this.#invalid_request(
740
+ session_id,
741
+ Array.isArray(parsed_body)
742
+ ? 'JSON-RPC batch requests are not supported'
743
+ : 'Expected a JSON-RPC message object',
744
+ );
745
+ }
746
+ const body = /** @type {Record<string, any>} */ (parsed_body);
747
+ const valid_id =
748
+ typeof body.id === 'string' || typeof body.id === 'number';
749
+ const request_message =
750
+ body.jsonrpc === '2.0' &&
751
+ typeof body.method === 'string' &&
752
+ (body.id === undefined || valid_id);
753
+ const has_result = Object.hasOwn(body, 'result');
754
+ const has_error = Object.hasOwn(body, 'error');
755
+ const response_message =
756
+ body.jsonrpc === '2.0' && valid_id && has_result !== has_error;
757
+ if (!request_message && !response_message) {
758
+ return this.#invalid_request(
759
+ session_id,
760
+ 'Expected a valid JSON-RPC request, notification, or response',
761
+ valid_id ? body.id : null,
762
+ );
763
+ }
764
+ if (per_request && response_message) {
765
+ return this.#invalid_request(
766
+ undefined,
767
+ 'Per-request clients must not send JSON-RPC responses',
768
+ body.id,
769
+ );
770
+ }
771
+ if (per_request && request_message) {
772
+ const preflight = await this.#preflight_per_request(
773
+ body,
774
+ request,
775
+ valid_id ? body.id : null,
776
+ );
777
+ if (preflight) return preflight;
778
+ }
395
779
 
396
- const messages = Array.isArray(body) ? body : [body];
780
+ const subscription_id =
781
+ body.method === 'subscriptions/listen' && valid_id
782
+ ? /** @type {string | number} */ (body.id)
783
+ : undefined;
784
+ const subscription_origin =
785
+ subscription_id !== undefined || session_id === undefined
786
+ ? crypto.randomUUID()
787
+ : session_id;
788
+ /** @type {{ id: string | number, registration: SubscriptionRegistration } | undefined} */
789
+ let subscription;
790
+ if (subscription_id !== undefined) {
791
+ /** @type {(registered: boolean) => void} */
792
+ let resolve = () => {};
793
+ const promise = /** @type {Promise<boolean>} */ (
794
+ new Promise((ready) => {
795
+ resolve = ready;
796
+ })
797
+ );
798
+ subscription = {
799
+ id: subscription_id,
800
+ registration: { promise, resolve },
801
+ };
802
+ }
803
+ const abort_controller = new AbortController();
804
+ /** @type {SubscriptionSink} */
805
+ const sink = {
806
+ controller: undefined,
807
+ state: 'open',
808
+ signal: abort_controller.signal,
809
+ subscription,
810
+ };
811
+ const manager = this.#subscription_manager;
812
+ const server_subscription_manager = this.#manager_for_sink(sink);
813
+ const register_sink = this.#register_subscription_sink.bind(this);
814
+ const delete_sink = this.#delete_subscription_sink.bind(this);
815
+ let released = false;
816
+ const release_sink = () => {
817
+ if (released) return;
818
+ released = true;
819
+ request.signal.removeEventListener('abort', on_request_abort);
820
+ const current = sink.subscription;
821
+ if (!current) return;
822
+ current.registration.resolve(false);
823
+ delete_sink(subscription_origin, current.id, sink);
824
+ };
825
+ /** @type {Promise<void> | undefined} */
826
+ let disconnecting;
827
+ /** @param {boolean} close_stream */
828
+ const disconnect = (close_stream) => {
829
+ if (disconnecting) return disconnecting;
830
+ disconnecting = (async () => {
831
+ sink.state = 'disconnected';
832
+ abort_controller.abort();
833
+ if (close_stream) {
834
+ try {
835
+ sink.controller?.close();
836
+ } catch {
837
+ // The response body may already have been cancelled.
838
+ }
839
+ }
840
+ const current = sink.subscription;
841
+ if (
842
+ !current ||
843
+ this.#subscription_sinks
844
+ .get(subscription_origin)
845
+ ?.get(current.id) !== sink
846
+ ) {
847
+ return;
848
+ }
849
+ const registered = await current.registration.promise;
850
+ delete_sink(subscription_origin, current.id, sink);
851
+ if (registered) {
852
+ await manager.close(
853
+ current.id,
854
+ subscription_origin,
855
+ 'cancelled',
856
+ );
857
+ }
858
+ })();
859
+ return disconnecting;
860
+ };
861
+ const on_request_abort = () => {
862
+ void disconnect(true);
863
+ };
864
+ request.signal.addEventListener('abort', on_request_abort, {
865
+ once: true,
866
+ });
397
867
 
398
- const handle = async () => {
399
- const init_message = messages.find(
400
- (/** @type {any} */ m) => m.method === 'initialize',
401
- );
868
+ const stream = new ReadableStream({
869
+ start(controller) {
870
+ sink.controller = controller;
871
+ if (subscription_id !== undefined) {
872
+ register_sink(subscription_origin, subscription_id, sink);
873
+ }
874
+ },
875
+ cancel: () => disconnect(false),
876
+ });
877
+ if (request.signal.aborted) void disconnect(true);
402
878
 
403
- const client_capabilities = init_message
879
+ const handle = async () => {
880
+ if (abort_controller.signal.aborted) {
881
+ release_sink();
882
+ return;
883
+ }
884
+ const init_message =
885
+ body.method === 'initialize' ? body : undefined;
886
+ const client_capabilities = session_id
887
+ ? init_message
404
888
  ? init_message.params?.capabilities
405
889
  : await this.#options.sessionManager.info
406
890
  .getClientCapabilities(session_id)
407
- .catch(() => undefined);
408
- const client_info = init_message
891
+ .catch(() => undefined)
892
+ : undefined;
893
+ const client_info = session_id
894
+ ? init_message
409
895
  ? init_message.params?.clientInfo
410
896
  : await this.#options.sessionManager.info
411
897
  .getClientInfo(session_id)
412
- .catch(() => undefined);
413
- const log_level = init_message
898
+ .catch(() => undefined)
899
+ : undefined;
900
+ const log_level = session_id
901
+ ? init_message
414
902
  ? undefined
415
903
  : await this.#options.sessionManager.info
416
904
  .getLogLevel(session_id)
417
- .catch(() => undefined);
418
-
419
- const response = await this.#controller_storage.run(
420
- controller,
421
- () =>
422
- session_id_storage.run(session_id, () =>
423
- this.#server.receive(body, {
424
- sessionId: session_id,
425
- auth: auth_info ?? undefined,
905
+ .catch(() => undefined)
906
+ : undefined;
907
+ if (abort_controller.signal.aborted) {
908
+ release_sink();
909
+ return;
910
+ }
911
+
912
+ const receive = () =>
913
+ this.#server.receive(/** @type {any} */ (body), {
914
+ ...(session_id ? { sessionId: session_id } : {}),
915
+ auth: auth_info ?? undefined,
916
+ ...(session_id
917
+ ? {
426
918
  sessionInfo: {
427
919
  clientCapabilities: client_capabilities,
428
920
  clientInfo: client_info,
429
921
  logLevel: log_level,
430
922
  },
431
- custom: ctx,
432
- }),
433
- ),
434
- );
923
+ }
924
+ : {}),
925
+ custom: ctx,
926
+ signal: abort_controller.signal,
927
+ subscriptionOrigin: subscription_origin,
928
+ subscriptionManager: server_subscription_manager,
929
+ });
930
+ const response = await this.#controller_storage.run(sink, () =>
931
+ session_id
932
+ ? this.#session_id_storage.run(session_id, receive)
933
+ : receive(),
934
+ );
435
935
 
436
- controller?.enqueue(
936
+ if (
937
+ sink.state === 'open' &&
938
+ !abort_controller.signal.aborted &&
939
+ response != null
940
+ ) {
941
+ sink.controller?.enqueue(
437
942
  this.#text_encoder.encode(
438
943
  'event: message\ndata: ' +
439
944
  JSON.stringify(response) +
440
945
  '\n\n',
441
946
  ),
442
947
  );
443
- controller?.close();
444
- };
948
+ }
949
+ if (sink.state !== 'disconnected') sink.controller?.close();
950
+ release_sink();
951
+ };
445
952
 
446
- handle();
953
+ void handle().catch((error) => {
954
+ if (sink.state === 'open') {
955
+ sink.controller?.error(error);
956
+ } else if (sink.state === 'cancelled') {
957
+ try {
958
+ sink.controller?.close();
959
+ } catch {
960
+ // The cancellation may already have closed the stream.
961
+ }
962
+ }
963
+ release_sink();
964
+ });
447
965
 
448
- const has_request = messages.some((message) => message.id != null);
966
+ const has_request = request_message && valid_id;
967
+ const response = new Response(has_request ? stream : null, {
968
+ headers: has_request
969
+ ? {
970
+ 'Content-Type': 'text/event-stream',
971
+ 'Cache-Control': 'no-cache',
972
+ connection: 'keep-alive',
973
+ 'X-Accel-Buffering': 'no',
974
+ ...(session_id ? { 'mcp-session-id': session_id } : {}),
975
+ }
976
+ : undefined,
977
+ status: has_request ? 200 : 202,
978
+ });
979
+ if (subscription_id !== undefined) {
980
+ this.#subscription_responses.set(response, {
981
+ id: subscription_id,
982
+ origin: subscription_origin,
983
+ });
984
+ }
985
+ return response;
986
+ }
449
987
 
450
- // Determine status code based on response type
451
- // 202 Accepted for notifications/responses, 200 OK for standard requests
452
- const status = !has_request ? 202 : 200;
988
+ /**
989
+ * Gracefully complete one active per-request subscription.
990
+ * @param {Response} response
991
+ * @returns {Promise<boolean>}
992
+ */
993
+ async closeSubscription(response) {
994
+ const subscription = this.#subscription_responses.get(response);
995
+ if (!subscription) return false;
996
+ this.#subscription_responses.delete(response);
997
+ const sink = this.#subscription_sinks
998
+ .get(subscription.origin)
999
+ ?.get(subscription.id);
1000
+ if (!sink) return false;
1001
+ const registered = await sink.subscription?.registration.promise;
1002
+ if (!registered) return false;
1003
+ return this.#subscription_manager.close(
1004
+ subscription.id,
1005
+ subscription.origin,
1006
+ 'closed',
1007
+ );
1008
+ }
453
1009
 
454
- return new Response(has_request ? stream : null, {
455
- headers: has_request
456
- ? {
457
- 'Content-Type': 'text/event-stream',
458
- 'Cache-Control': 'no-cache',
459
- connection: 'keep-alive',
460
- 'mcp-session-id': session_id,
461
- }
462
- : undefined,
463
- status,
464
- });
465
- } catch (error) {
466
- // Handle JSON parsing errors
467
- return new Response(
468
- JSON.stringify({
469
- jsonrpc: '2.0',
470
- error: {
471
- code: -32700,
472
- message: 'Parse error',
473
- data: /** @type {Error} */ (error).message,
474
- },
475
- }),
476
- {
477
- status: 400,
478
- headers: {
479
- 'Content-Type': 'application/json',
480
- 'mcp-session-id': session_id,
481
- },
482
- },
483
- );
1010
+ /**
1011
+ * Close every active per-request subscription owned by this transport.
1012
+ */
1013
+ async close() {
1014
+ /** @type {Array<[string | number, string]>} */
1015
+ const owned = [];
1016
+ for (const [origin, sinks] of this.#subscription_sinks) {
1017
+ for (const id of sinks.keys()) {
1018
+ owned.push([id, origin]);
1019
+ }
484
1020
  }
1021
+ await Promise.all(
1022
+ owned.map(([id, origin]) => this.#cancel_subscription(id, origin)),
1023
+ );
485
1024
  }
486
1025
 
487
1026
  /**
488
1027
  *
489
1028
  * @param {string} method
1029
+ * @param {string} [allow]
490
1030
  * @returns
491
1031
  */
492
- #handle_default(method) {
1032
+ #handle_default(method, allow = 'GET, POST, DELETE, OPTIONS') {
493
1033
  return new Response(
494
1034
  JSON.stringify({
495
1035
  jsonrpc: '2.0',
@@ -503,7 +1043,7 @@ export class HttpTransport {
503
1043
  status: 405,
504
1044
  headers: {
505
1045
  'Content-Type': 'application/json',
506
- Allow: 'GET, POST, DELETE, OPTIONS',
1046
+ Allow: allow,
507
1047
  },
508
1048
  },
509
1049
  );
@@ -517,6 +1057,10 @@ export class HttpTransport {
517
1057
  */
518
1058
  async respond(request, ctx) {
519
1059
  const url = new URL(request.url);
1060
+ const path_matches = this.#path === null || url.pathname === this.#path;
1061
+ if (path_matches && !this.#allows_origin(request)) {
1062
+ return new Response('Forbidden origin', { status: 403 });
1063
+ }
520
1064
 
521
1065
  /**
522
1066
  * @type {AuthInfo | null}
@@ -546,14 +1090,15 @@ export class HttpTransport {
546
1090
  }
547
1091
 
548
1092
  // Check if the request path matches the configured MCP path
549
- if (url.pathname !== this.#path && this.#path !== null) {
1093
+ if (!path_matches) {
550
1094
  return null;
551
1095
  }
552
1096
 
553
1097
  const method = request.method;
554
- const session_id =
555
- request.headers.get('mcp-session-id') ||
556
- this.#options.getSessionId();
1098
+ const per_request_http_method =
1099
+ getPerRequestProtocolVersions().includes(
1100
+ request.headers.get('mcp-protocol-version') ?? '',
1101
+ );
557
1102
 
558
1103
  /**
559
1104
  * @type {Response | null}
@@ -571,20 +1116,25 @@ export class HttpTransport {
571
1116
  }
572
1117
  // Handle DELETE request - disconnect session
573
1118
  else if (method === 'DELETE') {
574
- response = await this.#handle_delete(session_id);
1119
+ response = per_request_http_method
1120
+ ? this.#handle_default(method, 'POST, OPTIONS')
1121
+ : await this.#handle_delete(
1122
+ request.headers.get('mcp-session-id') ||
1123
+ this.#options.getSessionId(),
1124
+ );
575
1125
  }
576
1126
  // Handle GET request - establish long-lived connection for notifications
577
1127
  else if (method === 'GET') {
578
- response = await this.#handle_get(session_id);
1128
+ response = per_request_http_method
1129
+ ? this.#handle_default(method, 'POST, OPTIONS')
1130
+ : await this.#handle_get(
1131
+ request.headers.get('mcp-session-id') ||
1132
+ this.#options.getSessionId(),
1133
+ );
579
1134
  }
580
1135
  // Handle POST request - process message and respond through event stream
581
1136
  else if (method === 'POST') {
582
- response = await this.#handle_post(
583
- session_id,
584
- request,
585
- auth_info,
586
- ctx,
587
- );
1137
+ response = await this.#handle_post(request, auth_info, ctx);
588
1138
  }
589
1139
  // Method not supported
590
1140
  else {