import { spawn } from 'child_process'
import { StringDecoder } from 'node:string_decoder'
import express from 'express'
import cors, { type CorsOptions } from 'cors'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import {
JSONRPCMessage,
isInitializeRequest,
isInitializedNotification,
} from '@modelcontextprotocol/sdk/types.js'
import { Logger } from '../types.js'
import { getVersion } from '../lib/getVersion.js'
import { onSignals } from '../lib/onSignals.js'
import { OwnedChildProcesses } from '../lib/ownedChildProcesses.js'
import { createModernHttp } from '../lib/modernHttp.js'
import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js'
import { describeHeaders } from '../lib/headers.js'
import { escapeSseJsonSeparators } from '../lib/escapeSseJsonSeparators.js'
import { jsonBodyErrors } from '../lib/jsonBodyErrors.js'
import { LineSplitter } from '../lib/lineSplitter.js'
import { keepConnectionsAlive } from '../lib/keepConnectionsAlive.js'
import { drained, holdOutput } from '../lib/outputBackpressure.js'
export interface StdioToStreamableHttpArgs {
stdioCmd: string
port: number
streamableHttpPath: string
logger: Logger
corsOrigin: CorsOptions['origin']
healthEndpoints: string[]
headers: Record<string, string>
protocolVersion: string
}
const setResponseHeaders = ({
res,
headers,
}: {
res: express.Response
headers: Record<string, string>
}) =>
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value)
})
const createInitializeRequest = (
id: string | number,
protocolVersion: string,
): JSONRPCMessage => ({
jsonrpc: '2.0',
id,
method: 'initialize',
params: {
protocolVersion,
capabilities: {},
clientInfo: {
name: 'supergateway',
version: getVersion(),
},
},
})
const createInitializedNotification = (): JSONRPCMessage => ({
jsonrpc: '2.0',
method: 'notifications/initialized',
})
export async function stdioToStatelessStreamableHttp(
args: StdioToStreamableHttpArgs,
) {
const {
stdioCmd,
port,
streamableHttpPath,
logger,
corsOrigin,
healthEndpoints,
headers,
protocolVersion,
} = args
logger.info(` - Headers: ${describeHeaders(headers)}`)
logger.info(` - port: ${port}`)
logger.info(` - stdio: ${stdioCmd}`)
logger.info(` - streamableHttpPath: ${streamableHttpPath}`)
logger.info(` - protocolVersion: ${protocolVersion}`)
logger.info(
` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`,
)
logger.info(
` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`,
)
const children = new OwnedChildProcesses(logger)
const modern = createModernHttp({ stdioCmd, children, logger })
onSignals({
logger,
cleanup: async () => {
await Promise.all([modern.close(), children.close()])
},
drainStdin: true,
})
const app = express()
app.use((_req, res, next) => {
escapeSseJsonSeparators(res)
setResponseHeaders({ res, headers })
next()
})
app.use(express.json({ limit: '4mb' }), jsonBodyErrors)
if (corsOrigin) {
app.use(cors({ origin: corsOrigin }))
}
for (const ep of healthEndpoints) {
app.get(ep, (_req, res) => {
res.send('ok')
})
}
app.post(streamableHttpPath, async (req, res) => {
if (children.closing) {
res.status(503).send('Gateway is shutting down')
return
}
if (await modern.handle(req, res)) return
try {
const server = new Server(
{ name: 'supergateway', version: getVersion() },
{ capabilities: {} },
)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
await server.connect(transport)
const child = spawn(stdioCmd, children.spawnOptions)
const stop = children.own(child)
const pendingRequests = new Set<string | number>()
let childFailed = false
let released = false
let finishTimer: NodeJS.Timeout | undefined
const handleChildFailure = (err?: Error) => {
if (childFailed) return
childFailed = true
released = true
clearTimeout(finishTimer)
if (err) logger.error('Child process failure:', err)
void stop()
const replies = [...pendingRequests].map((id) =>
transport
.send({
jsonrpc: '2.0',
id,
error: { code: -32603, message: 'MCP server process failed' },
})
.catch((sendError) => {
logger.error('Failed to send child failure', sendError)
}),
)
pendingRequests.clear()
void Promise.all(replies)
.then(() => transport.close())
.catch((closeError) => {
logger.error(
'Failed to close transport after child failure',
closeError,
)
})
.finally(() => {
if (!res.writableEnded) res.destroy()
})
}
child.on('error', handleChildFailure)
child.stdin.on('error', handleChildFailure)
child.on('exit', (code, signal) => {
logger.error(`Child exited: code=${code}, signal=${signal}`)
handleChildFailure()
})
let initializeRequestId: string | number | null = null
let isAutoInitializing = false
const pendingOriginalMessages: JSONRPCMessage[] = []
let responseClosed = false
let handled = false
let hasOneWayMessage = false
const release = () => {
if (released) return
released = true
void stop()
server.close().catch((error) => {
logger.error('Failed to close completed stateless request', error)
})
}
const finishRequest = () => {
if (
released ||
finishTimer ||
!handled ||
!responseClosed ||
pendingRequests.size ||
isAutoInitializing
)
return
if (hasOneWayMessage) {
child.stdin.end()
finishTimer = setTimeout(release, 5000)
} else release()
}
res.once('close', () => {
responseClosed = true
finishRequest()
})
const decoder = new StringDecoder('utf8')
const lines = new LineSplitter()
child.stdout.on('data', (chunk: Buffer) => {
lines.push(decoder.write(chunk)).forEach((line) => {
if (!line.trim()) return
try {
const jsonMsg = JSON.parse(line)
logger.info('Child → StreamableHttp:', line)
if ('method' in jsonMsg && 'id' in jsonMsg) {
child.stdin.write(
JSON.stringify({
jsonrpc: '2.0',
id: jsonMsg.id,
...(jsonMsg.method === 'ping'
? { result: {} }
: {
error: {
code: -32601,
message:
'Server-to-client requests are not supported in stateless mode',
},
}),
}) + '\n',
)
return
}
if ('id' in jsonMsg) {
pendingRequests.delete(jsonMsg.id)
}
if (initializeRequestId && jsonMsg.id === initializeRequestId) {
logger.info('Initialize response received')
if (isAutoInitializing) {
const initializedNotification = createInitializedNotification()
logger.info(
`StreamableHttp → Child (initialized): ${JSON.stringify(initializedNotification)}`,
)
child.stdin.write(
JSON.stringify(initializedNotification) + '\n',
)
pendingOriginalMessages.splice(0).forEach((original) => {
logger.info(
`StreamableHttp → Child (original): ${JSON.stringify(original)}`,
)
child.stdin.write(JSON.stringify(original) + '\n')
})
isAutoInitializing = false
initializeRequestId = null
finishRequest()
return
} else {
initializeRequestId = null
}
}
void transport
.send(jsonMsg, {
relatedRequestId: pendingRequests.values().next().value,
})
.catch((e) => {
logger.error(`Failed to send to StreamableHttp`, e)
})
.finally(finishRequest)
} catch {
logger.error(`Child non-JSON: ${line}`)
}
})
holdOutput(child.stdout, drained([res]))
})
child.stderr.on('data', (chunk: Buffer) => {
logger.error(`Child stderr: ${chunk.toString('utf8')}`)
})
transport.onmessage = (msg: JSONRPCMessage) => {
if ('id' in msg && 'method' in msg) pendingRequests.add(msg.id!)
else hasOneWayMessage = true
if (!('id' in msg) && isInitializedNotification(msg)) {
logger.info('Client initialized; this child was initialized here')
return
}
logger.info(`StreamableHttp → Child: ${JSON.stringify(msg)}`)
if (!isInitializeRequest(msg)) {
pendingOriginalMessages.push(msg)
if (isAutoInitializing) return
initializeRequestId = `init_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
isAutoInitializing = true
logger.info(
'Non-initialize message detected, sending auto-initialize request first',
)
const initRequest = createInitializeRequest(
initializeRequestId,
(req.headers['mcp-protocol-version'] as string | undefined) ??
protocolVersion,
)
logger.info(
`StreamableHttp → Child (auto-initialize): ${JSON.stringify(initRequest)}`,
)
child.stdin.write(JSON.stringify(initRequest) + '\n')
return
}
if ('id' in msg) {
initializeRequestId = msg.id!
isAutoInitializing = false
logger.info(`Tracking initialize request ID: ${msg.id}`)
}
child.stdin.write(
JSON.stringify({
...msg,
params: { ...msg.params, capabilities: {} },
}) + '\n',
)
}
transport.onclose = () => {
logger.info('StreamableHttp connection closed')
void stop()
}
transport.onerror = (err) => {
logger.error(`StreamableHttp error:`, err)
void stop()
}
try {
await transport.handleRequest(req, res, req.body)
} catch (error) {
release()
throw error
} finally {
handled = true
finishRequest()
}
} catch (error) {
logger.error('Error handling MCP request:', error)
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
})
}
}
})
app.get(streamableHttpPath, async (req, res) => {
logger.info('Received GET MCP request')
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.',
},
id: null,
}),
)
})
app.delete(streamableHttpPath, async (req, res) => {
logger.info('Received DELETE MCP request')
res.writeHead(405).end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed.',
},
id: null,
}),
)
})
keepConnectionsAlive(
app.listen(port, () => {
logger.info(`Listening on port ${port}`)
logger.info(
`StreamableHttp endpoint: http://localhost:${port}${streamableHttpPath}`,
)
}),
)
}