@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/README.md +60 -19
- package/package.json +6 -6
- package/src/index.js +684 -134
- package/src/request-headers.js +251 -0
- package/src/types/index.d.ts +24 -1
- package/src/types/index.d.ts.map +3 -1
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<
|
|
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(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
277
|
+
} catch {
|
|
278
|
+
// The response stream may have closed before background work sent.
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
}
|
|
175
283
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|
|
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(
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
948
|
+
}
|
|
949
|
+
if (sink.state !== 'disconnected') sink.controller?.close();
|
|
950
|
+
release_sink();
|
|
951
|
+
};
|
|
445
952
|
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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:
|
|
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 (
|
|
1093
|
+
if (!path_matches) {
|
|
550
1094
|
return null;
|
|
551
1095
|
}
|
|
552
1096
|
|
|
553
1097
|
const method = request.method;
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
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 =
|
|
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 =
|
|
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 {
|