11import { randomUUID } from 'node:crypto'
22import type { IncomingMessage , ServerResponse } from 'node:http'
33import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
4+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
45import type { SevLogger } from '@transloadit/sev-logger'
56import {
67 applyCorsHeaders ,
8+ isAuthorized ,
79 isBasicAuthorized ,
810 normalizePath ,
911 parsePathname ,
1012} from './http-helpers.ts'
11- import { createMcpRequestHandler } from './http-request-handler.ts'
1213import { getMetrics , getMetricsContentType } from './metrics.ts'
1314import type { TransloaditMcpServerOptions } from './server.ts'
1415import { createTransloaditMcpServer } from './server.ts'
@@ -35,31 +36,38 @@ export type TransloaditMcpHttpHandler = ((
3536
3637const defaultPath = '/mcp'
3738
39+ /** Read the full request body and JSON-parse it so `isInitializeRequest` can inspect the payload. */
40+ async function readJsonBody ( req : IncomingMessage ) : Promise < unknown > {
41+ return new Promise ( ( resolve , reject ) => {
42+ const chunks : Buffer [ ] = [ ]
43+ req . on ( 'data' , ( chunk : Buffer ) => chunks . push ( chunk ) )
44+ req . on ( 'end' , ( ) => {
45+ const raw = Buffer . concat ( chunks ) . toString ( 'utf8' )
46+ if ( ! raw ) {
47+ resolve ( undefined )
48+ return
49+ }
50+ try {
51+ resolve ( JSON . parse ( raw ) )
52+ } catch {
53+ resolve ( undefined )
54+ }
55+ } )
56+ req . on ( 'error' , reject )
57+ } )
58+ }
59+
3860export const createTransloaditMcpHttpHandler = async (
3961 options : TransloaditMcpHttpOptions = { } ,
4062) : Promise < TransloaditMcpHttpHandler > => {
41- const server = createTransloaditMcpServer ( options )
42- const transport = new StreamableHTTPServerTransport ( {
43- sessionIdGenerator : options . sessionIdGenerator ?? ( ( ) => randomUUID ( ) ) ,
44- allowedOrigins : options . allowedOrigins ,
45- allowedHosts : options . allowedHosts ,
46- enableDnsRebindingProtection : options . enableDnsRebindingProtection ,
47- } )
48-
49- await server . connect ( transport )
50-
5163 const expectedPath = options . path ?? defaultPath
5264 const metricsPath =
5365 options . metricsPath === false ? undefined : normalizePath ( options . metricsPath ?? '/metrics' )
5466 const metricsAuth = options . metricsAuth
67+ const sessionIdGenerator = options . sessionIdGenerator ?? ( ( ) => randomUUID ( ) )
5568
56- const mcpHandler = createMcpRequestHandler ( transport , {
57- allowedOrigins : options . allowedOrigins ,
58- mcpToken : options . mcpToken ,
59- path : { expectedPath } ,
60- logger : options . logger ,
61- redactSecrets : [ options . mcpToken , options . authKey , options . authSecret ] ,
62- } )
69+ // Per-session transport map: each MCP client gets its own transport + server pair.
70+ const transports = new Map < string , StreamableHTTPServerTransport > ( )
6371
6472 const serverCardJson = JSON . stringify (
6573 buildServerCard ( expectedPath , { authKey : options . authKey , authSecret : options . authSecret } ) ,
@@ -69,7 +77,6 @@ export const createTransloaditMcpHttpHandler = async (
6977 const pathname = normalizePath ( parsePathname ( req . url , expectedPath ) )
7078
7179 if ( pathname === serverCardPath ) {
72- // Public discovery endpoint for registries; always allow CORS (optionally restricted by allowedOrigins).
7380 if ( ! applyCorsHeaders ( req , res , options . allowedOrigins ) ) {
7481 return
7582 }
@@ -115,11 +122,131 @@ export const createTransloaditMcpHttpHandler = async (
115122 return
116123 }
117124
118- await mcpHandler ( req , res )
125+ if ( pathname !== normalizePath ( expectedPath ) ) {
126+ res . statusCode = 404
127+ res . end ( 'Not Found' )
128+ return
129+ }
130+
131+ if ( ! applyCorsHeaders ( req , res , options . allowedOrigins ) ) {
132+ return
133+ }
134+
135+ if ( req . method === 'OPTIONS' ) {
136+ res . statusCode = 204
137+ res . end ( )
138+ return
139+ }
140+
141+ if ( options . mcpToken && ! isAuthorized ( req , options . mcpToken ) ) {
142+ res . statusCode = 401
143+ res . setHeader ( 'WWW-Authenticate' , 'Bearer' )
144+ res . end ( 'Unauthorized' )
145+ return
146+ }
147+
148+ // Bare GETs without the SSE Accept header are not valid MCP requests (the
149+ // Streamable HTTP spec requires Accept: text/event-stream for GET). Return
150+ // a friendly JSON status so directory health-probes see a 200 instead of 406.
151+ const accept = req . headers . accept ?? ''
152+ if ( req . method === 'GET' && ! accept . includes ( 'text/event-stream' ) ) {
153+ res . statusCode = 200
154+ res . setHeader ( 'Content-Type' , 'application/json' )
155+ res . end (
156+ JSON . stringify ( {
157+ name : 'Transloadit MCP Server' ,
158+ status : 'ok' ,
159+ docs : 'https://transloadit.com/docs/sdks/mcp-server/' ,
160+ } ) ,
161+ )
162+ return
163+ }
164+
165+ // Route request to the correct per-session transport.
166+ const sessionId = req . headers [ 'mcp-session-id' ] as string | undefined
167+ let transport : StreamableHTTPServerTransport | undefined
168+
169+ if ( sessionId ) {
170+ transport = transports . get ( sessionId )
171+ if ( ! transport ) {
172+ res . statusCode = 404
173+ res . setHeader ( 'Content-Type' , 'application/json' )
174+ res . end (
175+ JSON . stringify ( {
176+ jsonrpc : '2.0' ,
177+ error : { code : - 32000 , message : 'Session not found' } ,
178+ id : null ,
179+ } ) ,
180+ )
181+ return
182+ }
183+ }
184+
185+ // For POST requests without a session, read the body to check for initialization.
186+ let parsedBody : unknown
187+ if ( req . method === 'POST' && ! transport ) {
188+ parsedBody = await readJsonBody ( req )
189+ if ( isInitializeRequest ( parsedBody ) ) {
190+ transport = new StreamableHTTPServerTransport ( {
191+ sessionIdGenerator,
192+ allowedOrigins : options . allowedOrigins ,
193+ allowedHosts : options . allowedHosts ,
194+ enableDnsRebindingProtection : options . enableDnsRebindingProtection ,
195+ onsessioninitialized : ( sid ) => {
196+ transports . set ( sid , transport ! )
197+ } ,
198+ } )
199+
200+ transport . onclose = ( ) => {
201+ const sid = transport ! . sessionId
202+ if ( sid ) {
203+ transports . delete ( sid )
204+ }
205+ }
206+
207+ const server = createTransloaditMcpServer ( options )
208+ await server . connect ( transport )
209+ } else {
210+ res . statusCode = 400
211+ res . setHeader ( 'Content-Type' , 'application/json' )
212+ res . end (
213+ JSON . stringify ( {
214+ jsonrpc : '2.0' ,
215+ error : { code : - 32600 , message : 'Bad Request: No valid session ID provided' } ,
216+ id : null ,
217+ } ) ,
218+ )
219+ return
220+ }
221+ }
222+
223+ if ( ! transport ) {
224+ res . statusCode = 400
225+ res . setHeader ( 'Content-Type' , 'application/json' )
226+ res . end (
227+ JSON . stringify ( {
228+ jsonrpc : '2.0' ,
229+ error : { code : - 32600 , message : 'Bad Request: No valid session ID provided' } ,
230+ id : null ,
231+ } ) ,
232+ )
233+ return
234+ }
235+
236+ try {
237+ await transport . handleRequest ( req , res , parsedBody )
238+ } catch ( error ) {
239+ if ( ! res . headersSent ) {
240+ res . statusCode = 500
241+ res . end ( 'Internal Server Error' )
242+ }
243+ }
119244 } ) as TransloaditMcpHttpHandler
120245
121246 handler . close = async ( ) => {
122- await transport . close ( )
247+ const closePromises = [ ...transports . values ( ) ] . map ( ( t ) => t . close ( ) )
248+ await Promise . all ( closePromises )
249+ transports . clear ( )
123250 }
124251
125252 return handler
0 commit comments