@tmcp/transport-http
Version:
Transport for TMCP using HTTP
602 lines (541 loc) • 14.5 kB
JavaScript
/**
* @import { AuthInfo, McpServer } from "tmcp";
* @import { OAuth } from "@tmcp/auth";
* @import { StreamSessionManager, InfoSessionManager } from "@tmcp/session-manager";
* @import { OptionalizeSessionManager } from "./type-utils.js"
*/
/**
* @typedef {{
* origin?: string | string[] | boolean
* methods?: string[]
* allowedHeaders?: string[]
* exposedHeaders?: string[]
* credentials?: boolean
* maxAge?: number
* }} CorsConfig
*/
/**
* @typedef {{
* getSessionId?: () => string
* path?: string | null
* oauth?: OAuth<"built">
* cors?: CorsConfig | boolean,
* sessionManager?: { streams?: StreamSessionManager, info?: OptionalizeSessionManager<InfoSessionManager> }
* disableSse?: boolean
* }} HttpTransportOptions
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import {
InMemoryStreamSessionManager,
InMemoryInfoSessionManager,
} from '@tmcp/session-manager';
import { DEV } from 'esm-env';
/**
* @template {Record<string, unknown> | undefined} [TCustom=undefined]
*/
export class HttpTransport {
/**
* @typedef {NonNullable<Required<Pick<HttpTransportOptions, "sessionManager">["sessionManager"]>>} SessionManager
*/
/**
* @type {McpServer<any, TCustom>}
*/
#server;
/**
* @type {Required<Omit<HttpTransportOptions, 'oauth' | 'cors' | 'sessionManager' | 'disableSse'>> & { cors?: CorsConfig | boolean, sessionManager: SessionManager, disableSse?: boolean }}
*/
#options;
/**
* @type {string | null}
*/
#path;
/**
* @type {AsyncLocalStorage<ReadableStreamDefaultController | undefined>}
*/
#controller_storage = new AsyncLocalStorage();
/**
* @type {AsyncLocalStorage<string>}
*/
#session_id_storage = new AsyncLocalStorage();
/**
* @type {OAuth<"built"> | undefined}
*/
#oauth;
#text_encoder = new TextEncoder();
/**
*
* @param {McpServer<any, TCustom>} server
* @param {HttpTransportOptions} [options]
*/
constructor(server, options) {
this.#server = server;
const {
getSessionId = () => crypto.randomUUID(),
path = '/mcp',
oauth,
cors,
disableSse,
sessionManager: _sessionManager = {
streams: new InMemoryStreamSessionManager(),
info: new InMemoryInfoSessionManager(),
},
} = options ?? {
getSessionId: () => crypto.randomUUID(),
};
/**
* @type {SessionManager}
*/
const sessionManager = {
streams:
_sessionManager.streams ?? new InMemoryStreamSessionManager(),
info: _sessionManager.info ?? new InMemoryInfoSessionManager(),
};
if (options?.path === undefined && DEV) {
// TODO: remove on 1.0.0 release
console.warn(
"[tmcp][transport-http] `options.path` is undefined, in future versions passing `undefined` will default to respond on all paths. To keep the current behavior, explicitly set `path` to '/mcp' or your desired path.",
);
}
if (oauth) {
this.#oauth = oauth;
}
this.#options = {
getSessionId,
path,
cors,
sessionManager,
disableSse,
};
this.#path = path;
this.#server.on('initialize', ({ capabilities, clientInfo }) => {
const sessionId = this.#session_id_storage.getStore();
if (!sessionId) return;
this.#options.sessionManager.info.setClientCapabilities(
sessionId,
capabilities,
);
this.#options.sessionManager.info.setClientInfo(
sessionId,
clientInfo,
);
});
this.#server.on('subscription', async ({ uri, action }) => {
const sessionId = this.#session_id_storage.getStore();
if (!sessionId) return;
if (action === 'remove') {
this.#options.sessionManager.info.removeSubscription?.(
sessionId,
uri,
);
} else {
this.#options.sessionManager.info.addSubscription(
sessionId,
uri,
);
}
});
this.#server.on('loglevelchange', ({ level }) => {
const sessionId = this.#session_id_storage.getStore();
if (!sessionId) return;
this.#options.sessionManager.info.setLogLevel(sessionId, level);
});
this.#server.on('broadcast', async ({ request }) => {
let sessions = undefined;
if (request.method === 'notifications/resources/updated') {
sessions =
await this.#options.sessionManager.info.getSubscriptions(
request.params.uri,
);
}
await this.#options.sessionManager.streams.send(
sessions,
'event: message\ndata: ' + JSON.stringify(request) + '\n\n',
);
});
this.#server.on('send', async ({ request }) => {
// use the current controller if the request has an id (it means it's a request and not a notification)
const controller = this.#controller_storage.getStore();
if (!controller) return;
controller.enqueue(
this.#text_encoder.encode(
'event: message\ndata: ' + JSON.stringify(request) + '\n\n',
),
);
});
}
/**
* Applies CORS headers to a response based on the configuration
* @param {Response} response - The response to modify
* @param {Request} request - The original request
*/
#apply_cors_headers(response, request) {
const cors_config = this.#options.cors;
if (!cors_config) {
return;
}
// Handle boolean true (allow all origins)
if (cors_config === true) {
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set(
'Access-Control-Allow-Methods',
'GET, POST, DELETE, OPTIONS',
);
response.headers.set('Access-Control-Allow-Headers', '*');
return;
}
// Handle detailed configuration
const config = /** @type {CorsConfig} */ (cors_config);
const origin = request.headers.get('origin');
// Handle origin
if (config.origin !== undefined) {
if (config.origin === true || config.origin === '*') {
response.headers.set('Access-Control-Allow-Origin', '*');
} else if (typeof config.origin === 'string') {
if (origin === config.origin) {
response.headers.set(
'Access-Control-Allow-Origin',
config.origin,
);
}
} else if (Array.isArray(config.origin)) {
if (origin && config.origin.includes(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin);
}
}
}
// Handle other CORS headers with defaults
const methods = config.methods ?? ['GET', 'POST', 'DELETE', 'OPTIONS'];
response.headers.set(
'Access-Control-Allow-Methods',
methods.join(', '),
);
const allowed_headers = config.allowedHeaders ?? '*';
if (Array.isArray(allowed_headers)) {
response.headers.set(
'Access-Control-Allow-Headers',
allowed_headers.join(', '),
);
} else {
response.headers.set(
'Access-Control-Allow-Headers',
allowed_headers,
);
}
if (config.exposedHeaders) {
response.headers.set(
'Access-Control-Expose-Headers',
config.exposedHeaders.join(', '),
);
}
if (config.credentials) {
response.headers.set('Access-Control-Allow-Credentials', 'true');
}
if (config.maxAge !== undefined) {
response.headers.set(
'Access-Control-Max-Age',
config.maxAge.toString(),
);
}
}
/**
* @param {string} session_id
*/
async #handle_delete(session_id) {
await this.#options.sessionManager.streams.delete(session_id);
await this.#options.sessionManager.info.delete(session_id);
return new Response(null, {
status: 200,
headers: {
'mcp-session-id': session_id,
},
});
}
/**
*
* @param {string} session_id
* @returns
*/
async #handle_get(session_id) {
if (this.#options.disableSse) {
return new Response(null, {
status: 405,
headers: {
Allow: 'POST, DELETE, OPTIONS',
},
});
}
const sessions = this.#options.sessionManager;
const text_encoder = this.#text_encoder;
// If session already exists, return error
const existing_session = await sessions.streams.has(session_id);
if (existing_session) {
return new Response(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message:
'Conflict: Only one SSE stream is allowed per session',
},
id: null,
}),
{
headers: {
'Content-Type': 'application/json',
'mcp-session-id': session_id,
},
status: 409,
},
);
}
// Create new long-lived stream for notifications
const stream = new ReadableStream({
async start(controller) {
await sessions.streams.create(session_id, controller);
// send a comment to flush the headers immediately
controller.enqueue(text_encoder.encode(': connected\n\n'));
},
async cancel() {
await sessions.streams.delete(session_id);
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'mcp-session-id': session_id,
},
status: 200,
});
}
/**
*
* @param {string} session_id
* @param {Request} request
* @param {AuthInfo | null} auth_info
* @param {TCustom} [ctx]
*/
async #handle_post(session_id, request, auth_info, ctx) {
// Check Content-Type header
const content_type = request.headers.get('content-type');
if (!content_type || !content_type.includes('application/json')) {
return new Response(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32600,
message: 'Invalid Request',
data: 'Content-Type must be application/json',
},
}),
{
status: 415,
headers: {
'Content-Type': 'application/json',
'mcp-session-id': session_id,
},
},
);
}
try {
const body = await request.clone().json();
/**
* @type {ReadableStreamDefaultController | undefined}
*/
let controller;
// Create a short-lived stream that closes after sending the response
const stream = new ReadableStream({
start(_controller) {
controller = _controller;
},
});
const session_id_storage = this.#session_id_storage;
const messages = Array.isArray(body) ? body : [body];
const handle = async () => {
const init_message = messages.find(
(/** @type {any} */ m) => m.method === 'initialize',
);
const client_capabilities = init_message
? init_message.params?.capabilities
: await this.#options.sessionManager.info
.getClientCapabilities(session_id)
.catch(() => undefined);
const client_info = init_message
? init_message.params?.clientInfo
: await this.#options.sessionManager.info
.getClientInfo(session_id)
.catch(() => undefined);
const log_level = init_message
? undefined
: await this.#options.sessionManager.info
.getLogLevel(session_id)
.catch(() => undefined);
const response = await this.#controller_storage.run(
controller,
() =>
session_id_storage.run(session_id, () =>
this.#server.receive(body, {
sessionId: session_id,
auth: auth_info ?? undefined,
sessionInfo: {
clientCapabilities: client_capabilities,
clientInfo: client_info,
logLevel: log_level,
},
custom: ctx,
}),
),
);
controller?.enqueue(
this.#text_encoder.encode(
'event: message\ndata: ' +
JSON.stringify(response) +
'\n\n',
),
);
controller?.close();
};
handle();
const has_request = messages.some((message) => message.id != null);
// Determine status code based on response type
// 202 Accepted for notifications/responses, 200 OK for standard requests
const status = !has_request ? 202 : 200;
return new Response(has_request ? stream : null, {
headers: has_request
? {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
connection: 'keep-alive',
'mcp-session-id': session_id,
}
: undefined,
status,
});
} catch (error) {
// Handle JSON parsing errors
return new Response(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32700,
message: 'Parse error',
data: /** @type {Error} */ (error).message,
},
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
'mcp-session-id': session_id,
},
},
);
}
}
/**
*
* @param {string} method
* @returns
*/
#handle_default(method) {
return new Response(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32601,
message: 'Method not found',
data: `HTTP method ${method} not supported`,
},
}),
{
status: 405,
headers: {
'Content-Type': 'application/json',
Allow: 'GET, POST, DELETE, OPTIONS',
},
},
);
}
/**
*
* @param {Request} request
* @param {TCustom} [ctx]
* @returns {Promise<Response | null>}
*/
async respond(request, ctx) {
const url = new URL(request.url);
/**
* @type {AuthInfo | null}
*/
let auth_info = null;
// Check if OAuth helper should handle this request
if (this.#oauth) {
try {
const response = await this.#oauth.respond(request);
if (response) {
return response;
}
} catch (error) {
return new Response(
JSON.stringify({
error: 'server_error',
error_description: /** @type {Error} */ (error).message,
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
);
}
auth_info = await this.#oauth.verify(request);
}
// Check if the request path matches the configured MCP path
if (url.pathname !== this.#path && this.#path !== null) {
return null;
}
const method = request.method;
const session_id =
request.headers.get('mcp-session-id') ||
this.#options.getSessionId();
/**
* @type {Response | null}
*/
let response = null;
// Handle OPTIONS request - preflight CORS
if (method === 'OPTIONS') {
response = new Response(null, {
status: 204,
headers: {
'Content-Type': 'application/json',
},
});
}
// Handle DELETE request - disconnect session
else if (method === 'DELETE') {
response = await this.#handle_delete(session_id);
}
// Handle GET request - establish long-lived connection for notifications
else if (method === 'GET') {
response = await this.#handle_get(session_id);
}
// Handle POST request - process message and respond through event stream
else if (method === 'POST') {
response = await this.#handle_post(
session_id,
request,
auth_info,
ctx,
);
}
// Method not supported
else {
response = this.#handle_default(method);
}
// Apply CORS headers if we have a response
if (response) {
this.#apply_cors_headers(response, request);
}
return response;
}
}