├── .gitignore ├── docker ├── deno.Dockerfile ├── uvx.Dockerfile └── base.Dockerfile ├── supergateway.png ├── .prettierrc ├── src ├── types.ts ├── lib │ ├── serializeCorsOrigin.ts │ ├── getVersion.ts │ ├── corsOrigin.ts │ ├── onSignals.ts │ ├── headers.ts │ ├── getLogger.ts │ └── sessionAccessCounter.ts ├── server │ └── websocket.ts ├── gateways │ ├── stdioToWs.ts │ ├── stdioToSse.ts │ ├── sseToStdio.ts │ ├── streamableHttpToStdio.ts │ ├── stdioToStatefulStreamableHttp.ts │ └── stdioToStatelessStreamableHttp.ts └── index.ts ├── .prettierignore ├── tsconfig.build.json ├── tsconfig.test.json ├── tsconfig.json ├── .husky └── pre-commit ├── tests ├── streamableHttpCli.test.ts ├── baseUrl.test.ts ├── protocolVersion.test.ts ├── streamableHttpToStdio.test.ts ├── stdioToStatelessStreamableHttp.test.ts ├── stdioToStatefulStreamableHttp.test.ts ├── helpers │ └── mock-mcp-server.js └── concurrency.test.ts ├── AGENTS.md ├── LICENSE ├── docker-bake.hcl ├── package.json ├── .github └── workflows │ └── docker-publish.yaml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /docker/deno.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM base 2 | RUN curl -fsSL https://deno.land/install.sh | sh 3 | -------------------------------------------------------------------------------- /supergateway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supercorp-ai/supergateway/HEAD/supergateway.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "singleAttributePerLine": true 5 | } 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | info: (...args: any[]) => void 3 | error: (...args: any[]) => void 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .git 4 | .github 5 | package-lock.json 6 | .DS_Store 7 | **/.hermit/** 8 | **/cache/** 9 | -------------------------------------------------------------------------------- /docker/uvx.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM base 2 | RUN apk add --no-cache python3 coreutils 3 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 4 | -------------------------------------------------------------------------------- /docker/base.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | RUN npm install -g supergateway 4 | 5 | EXPOSE 8000 6 | 7 | ENTRYPOINT ["supergateway"] 8 | 9 | CMD ["--help"] 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist" 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "tests", 5 | "outDir": "dist-tests", 6 | "types": ["node"], 7 | "noEmitOnError": false 8 | }, 9 | "include": ["tests/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext", 4 | "moduleResolution": "NodeNext", 5 | "target": "ES2022", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "outDir": "dist" 10 | }, 11 | "include": ["src/**/*", "tests/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/serializeCorsOrigin.ts: -------------------------------------------------------------------------------- 1 | import type { CorsOptions } from 'cors' 2 | 3 | export const serializeCorsOrigin = ({ 4 | corsOrigin, 5 | }: { 6 | corsOrigin: CorsOptions['origin'] 7 | }) => 8 | JSON.stringify(corsOrigin, (_key, value) => { 9 | if (value instanceof RegExp) { 10 | return value.toString() 11 | } 12 | 13 | return value 14 | }) 15 | -------------------------------------------------------------------------------- /src/lib/getVersion.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url' 2 | import { join, dirname } from 'path' 3 | import { readFileSync } from 'fs' 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = dirname(__filename) 7 | 8 | export function getVersion(): string { 9 | try { 10 | const packageJsonPath = join(__dirname, '../../package.json') 11 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) 12 | return packageJson.version || '1.0.0' 13 | } catch (err) { 14 | console.error('[supergateway]', 'Unable to retrieve version:', err) 15 | return 'unknown' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # Ensure we're in the project directory 5 | GIT_ROOT=$(git rev-parse --show-toplevel) 6 | cd "$GIT_ROOT" || exit 1 7 | 8 | # Get staged files 9 | STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|json|css|scss|md|yaml|yml)$' || true) 10 | 11 | if [ -n "$STAGED_FILES" ]; then 12 | echo "Formatting staged files before commit..." 13 | # Use the installed Prettier to format only the staged files 14 | ./node_modules/.bin/prettier --write --ignore-unknown $STAGED_FILES 15 | # Re-add the formatted files to the staging area 16 | git add $STAGED_FILES 17 | fi -------------------------------------------------------------------------------- /src/lib/corsOrigin.ts: -------------------------------------------------------------------------------- 1 | export const corsOrigin = ({ 2 | argv, 3 | }: { 4 | argv: { 5 | cors: (string | number)[] | undefined 6 | } 7 | }) => { 8 | if (!argv.cors) { 9 | return false 10 | } 11 | 12 | if (argv.cors.length === 0) { 13 | return '*' 14 | } 15 | 16 | const origins = argv.cors.map((item) => `${item}`) 17 | 18 | if (origins.includes('*')) return '*' 19 | 20 | return origins.map((origin) => { 21 | if (/^\/.*\/$/.test(origin)) { 22 | const pattern = origin.slice(1, -1) 23 | try { 24 | return new RegExp(pattern) 25 | } catch (error) { 26 | return origin 27 | } 28 | } 29 | return origin 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /tests/streamableHttpCli.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import yargs from 'yargs' 4 | import { hideBin } from 'yargs/helpers' 5 | 6 | test('yargs parses streamableHttp outputTransport', () => { 7 | const argv = yargs( 8 | hideBin([ 9 | 'node', 10 | '', 11 | '--stdio', 12 | 'true', 13 | '--outputTransport', 14 | 'streamableHttp', 15 | ]), 16 | ) 17 | .option('stdio', { type: 'string' }) 18 | .option('outputTransport', { 19 | type: 'string', 20 | choices: ['stdio', 'sse', 'ws', 'streamableHttp'], 21 | }) 22 | .parseSync() 23 | assert.strictEqual(argv.outputTransport, 'streamableHttp') 24 | }) 25 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Install Node.js v24 using nvm. After checking out the repository, run: 4 | 5 | ```bash 6 | nvm install 24 7 | nvm use 24 8 | npm install 9 | 10 | # Build 11 | 12 | Compile the TypeScript sources before running tests: 13 | 14 | npm run build 15 | ``` 16 | 17 | ## Running tests 18 | 19 | Run the test suite with Node's test runner and ts-node to enable mocks: 20 | 21 | ```bash 22 | npm run test 23 | ``` 24 | 25 | The `tests/helpers/mock-mcp-server.js` script provides a lightweight local MCP 26 | server used during tests so everything runs offline. All tests should pass 27 | without external downloads. 28 | 29 | If network-dependent commands (like `npx -y @modelcontextprotocol/server-*`) fail, check network access. 30 | -------------------------------------------------------------------------------- /src/lib/onSignals.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../types.js' 2 | 3 | export interface OnSignalsOptions { 4 | logger: Logger 5 | cleanup?: () => void 6 | } 7 | 8 | /** 9 | * Sets up signal handlers for graceful shutdown. 10 | * 11 | * @param options Configuration options 12 | * @param options.logger Logger instance 13 | * @param options.cleanup Optional cleanup function to be called before exit 14 | */ 15 | export function onSignals(options: OnSignalsOptions): void { 16 | const { logger, cleanup } = options 17 | 18 | const handleSignal = (signal: string) => { 19 | logger.info(`Caught ${signal}. Exiting...`) 20 | if (cleanup) { 21 | cleanup() 22 | } 23 | process.exit(0) 24 | } 25 | 26 | process.on('SIGINT', () => handleSignal('SIGINT')) 27 | 28 | process.on('SIGTERM', () => handleSignal('SIGTERM')) 29 | 30 | process.on('SIGHUP', () => handleSignal('SIGHUP')) 31 | 32 | process.stdin.on('close', () => { 33 | logger.info('stdin closed. Exiting...') 34 | if (cleanup) { 35 | cleanup() 36 | } 37 | process.exit(0) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Supercorp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/headers.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../types.js' 2 | 3 | const parseHeaders = ({ 4 | argvHeader, 5 | logger, 6 | }: { 7 | argvHeader: (string | number)[] 8 | logger: Logger 9 | }): Record => { 10 | return argvHeader.reduce>((acc, rawHeader) => { 11 | const header = `${rawHeader}` 12 | 13 | const colonIndex = header.indexOf(':') 14 | if (colonIndex === -1) { 15 | logger.error(`Invalid header format: ${header}, ignoring`) 16 | return acc 17 | } 18 | 19 | const key = header.slice(0, colonIndex).trim() 20 | const value = header.slice(colonIndex + 1).trim() 21 | 22 | if (!key || !value) { 23 | logger.error(`Invalid header format: ${header}, ignoring`) 24 | return acc 25 | } 26 | 27 | acc[key] = value 28 | return acc 29 | }, {}) 30 | } 31 | 32 | export const headers = ({ 33 | argv, 34 | logger, 35 | }: { 36 | argv: { 37 | header: (string | number)[] 38 | oauth2Bearer: string | undefined 39 | } 40 | logger: Logger 41 | }): Record => { 42 | const headers = parseHeaders({ 43 | argvHeader: argv.header, 44 | logger, 45 | }) 46 | 47 | if ('oauth2Bearer' in argv) { 48 | return { 49 | ...headers, 50 | Authorization: `Bearer ${argv.oauth2Bearer}`, 51 | } 52 | } 53 | 54 | return headers 55 | } 56 | -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | variable "VERSION" { 2 | default = "DEV" 3 | } 4 | 5 | target "common" { 6 | context = "." 7 | platforms = ["linux/amd64", "linux/arm64"] 8 | } 9 | 10 | group "default" { 11 | targets = ["base", "uvx", "deno"] 12 | } 13 | 14 | target "base" { 15 | inherits = ["common"] 16 | dockerfile = "docker/base.Dockerfile" 17 | tags = [ 18 | "supercorp/supergateway:latest", 19 | "supercorp/supergateway:base", 20 | "supercorp/supergateway:${VERSION}", 21 | "ghcr.io/supercorp-ai/supergateway:latest", 22 | "ghcr.io/supercorp-ai/supergateway:base", 23 | "ghcr.io/supercorp-ai/supergateway:${VERSION}" 24 | ] 25 | } 26 | 27 | target "uvx" { 28 | inherits = ["common"] 29 | depends_on = ["base"] 30 | dockerfile = "docker/uvx.Dockerfile" 31 | contexts = { base = "target:base" } 32 | tags = [ 33 | "supercorp/supergateway:uvx", 34 | "supercorp/supergateway:${VERSION}-uvx", 35 | "ghcr.io/supercorp-ai/supergateway:uvx", 36 | "ghcr.io/supercorp-ai/supergateway:${VERSION}-uvx" 37 | ] 38 | } 39 | 40 | target "deno" { 41 | inherits = ["common"] 42 | depends_on = ["base"] 43 | dockerfile = "docker/deno.Dockerfile" 44 | contexts = { base = "target:base" } 45 | tags = [ 46 | "supercorp/supergateway:deno", 47 | "supercorp/supergateway:${VERSION}-deno", 48 | "ghcr.io/supercorp-ai/supergateway:deno", 49 | "ghcr.io/supercorp-ai/supergateway:${VERSION}-deno" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "supergateway", 3 | "version": "3.4.3", 4 | "description": "Run MCP stdio servers over SSE, Streamable HTTP or visa versa", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/supercorp-ai/supergateway.git" 8 | }, 9 | "keywords": [ 10 | "mcp", 11 | "stdio", 12 | "sse", 13 | "gateway", 14 | "proxy", 15 | "bridge" 16 | ], 17 | "type": "module", 18 | "bin": { 19 | "supergateway": "dist/index.js" 20 | }, 21 | "scripts": { 22 | "build": "tsc -p tsconfig.build.json", 23 | "start": "node dist/index.js", 24 | "format": "prettier --write 'src/**/*.ts' '*.json' '.prettierrc'", 25 | "format:check": "prettier --check 'src/**/*.ts' '*.json' '.prettierrc'", 26 | "test": "node --test --test-concurrency=1 --experimental-loader ts-node/esm --experimental-test-module-mocks tests/**/*.test.ts", 27 | "prepare": "husky" 28 | }, 29 | "lint-staged": { 30 | "**/*": "prettier --write --ignore-unknown" 31 | }, 32 | "dependencies": { 33 | "@modelcontextprotocol/sdk": "^1.18.2", 34 | "body-parser": "^1.20.3", 35 | "cors": "^2.8.5", 36 | "express": "^4.21.2", 37 | "uuid": "^11.1.0", 38 | "ws": "^8.18.2", 39 | "yargs": "^17.7.2", 40 | "zod": "^3.24.4" 41 | }, 42 | "devDependencies": { 43 | "@types/body-parser": "^1.19.5", 44 | "prev-modelcontextprotocol-sdk": "npm:@modelcontextprotocol/sdk@1.4.0", 45 | "@types/cors": "^2.8.18", 46 | "@types/express": "^5.0.2", 47 | "@types/node": "^22.15.18", 48 | "@types/ws": "^8.18.1", 49 | "@types/yargs": "^17.0.33", 50 | "husky": "^9.1.7", 51 | "lint-staged": "^16.0.0", 52 | "prettier": "^3.5.3", 53 | "ts-node": "^10.9.2", 54 | "tsx": "^4.19.4", 55 | "typescript": "^5.8.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/baseUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { spawn, ChildProcess } from 'child_process' 4 | 5 | const PORT = 11000 6 | const BASE_URL = `http://0.0.0.0:${PORT}` 7 | const SSE_PATH = '/sse' 8 | const MESSAGE_PATH = '/message' 9 | 10 | let gatewayProc: ChildProcess 11 | 12 | test.before(() => { 13 | gatewayProc = spawn( 14 | 'npm', 15 | [ 16 | 'run', 17 | 'start', 18 | '--', 19 | '--stdio', 20 | 'node tests/helpers/mock-mcp-server.js stdio', 21 | '--outputTransport', 22 | 'sse', 23 | '--port', 24 | String(PORT), 25 | '--baseUrl', 26 | BASE_URL, 27 | '--ssePath', 28 | SSE_PATH, 29 | '--messagePath', 30 | MESSAGE_PATH, 31 | ], 32 | { stdio: 'ignore', shell: false }, 33 | ) 34 | gatewayProc.unref() 35 | }) 36 | 37 | test.after(async () => { 38 | gatewayProc.kill('SIGINT') 39 | await new Promise((resolve) => gatewayProc.once('exit', resolve)) 40 | }) 41 | 42 | test('baseUrl should be passed correctly in endpoint event', async () => { 43 | const [{ Client }, { SSEClientTransport }] = await Promise.all([ 44 | import('@modelcontextprotocol/sdk/client/index.js'), 45 | import('@modelcontextprotocol/sdk/client/sse.js'), 46 | ]) 47 | 48 | const transport = new SSEClientTransport(new URL(SSE_PATH, BASE_URL)) 49 | const client = new Client({ name: 'endpoint-tester', version: '1.0.0' }) 50 | 51 | await new Promise((resolve) => setTimeout(resolve, 3000)) 52 | 53 | await client.connect(transport) 54 | const endpoint = (transport as any)._endpoint as URL | undefined 55 | await client.close() 56 | transport.close() 57 | 58 | assert.ok( 59 | endpoint && endpoint.href.startsWith(`${BASE_URL}${MESSAGE_PATH}`), 60 | `endpoint should start with "${BASE_URL}${MESSAGE_PATH}", got: ${endpoint?.href}`, 61 | ) 62 | }) 63 | -------------------------------------------------------------------------------- /tests/protocolVersion.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { spawn, ChildProcess } from 'child_process' 4 | 5 | import { Client } from 'prev-modelcontextprotocol-sdk/client/index.js' 6 | import { StdioClientTransport } from 'prev-modelcontextprotocol-sdk/client/stdio.js' 7 | 8 | const MCP_PORT = 11003 9 | const MCP_URL = `http://localhost:${MCP_PORT}/sse` 10 | 11 | let serverProc: ChildProcess | undefined 12 | 13 | function spawnMcpServer(): Promise { 14 | return new Promise((res, rej) => { 15 | const proc = spawn('node', ['tests/helpers/mock-mcp-server.js', 'sse'], { 16 | env: { ...process.env, PORT: String(MCP_PORT) }, 17 | shell: false, 18 | stdio: ['inherit', 'pipe', 'inherit'], 19 | }) 20 | 21 | proc.stdout.setEncoding('utf8') 22 | proc.stdout.on('data', (chunk: string) => { 23 | if (chunk.includes('Server is running on port')) { 24 | res(proc) 25 | } 26 | }) 27 | 28 | proc.on('error', rej) 29 | }) 30 | } 31 | 32 | test.before(async () => { 33 | serverProc = await spawnMcpServer() 34 | }) 35 | 36 | test.after(() => serverProc?.kill('SIGINT')) 37 | 38 | test('protocol version is passed', async () => { 39 | const gatewayCmd = ['npm', 'run', 'start', '--', '--sse', MCP_URL] 40 | 41 | const transport = new StdioClientTransport({ 42 | command: gatewayCmd[0], 43 | args: gatewayCmd.slice(1), 44 | }) 45 | 46 | const client = new Client({ name: 'gateway-test', version: '1.0.0' }) 47 | await client.connect(transport) 48 | 49 | const { tools } = await client.listTools() 50 | assert.ok(tools.some((t) => t.name === 'add')) 51 | 52 | type Reply = { content: Array<{ text: string }> } 53 | const reply = (await client.callTool({ 54 | name: 'add', 55 | arguments: { a: 2, b: 3 }, 56 | })) as Reply 57 | 58 | assert.strictEqual(reply.content[0].text, 'The sum of 2 and 3 is 5.') 59 | await client.close() 60 | }) 61 | -------------------------------------------------------------------------------- /tests/streamableHttpToStdio.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { spawn, ChildProcess } from 'child_process' 4 | 5 | import { Client } from '@modelcontextprotocol/sdk/client/index.js' 6 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' 7 | 8 | const MCP_PORT = 11002 9 | const MCP_URL = `http://localhost:${MCP_PORT}/mcp` 10 | 11 | let serverProc: ChildProcess | undefined 12 | 13 | function spawnMcpServer(): Promise { 14 | return new Promise((res, rej) => { 15 | const proc = spawn( 16 | 'node', 17 | ['tests/helpers/mock-mcp-server.js', 'streamableHttp'], 18 | { 19 | env: { ...process.env, PORT: String(MCP_PORT) }, 20 | shell: false, 21 | stdio: ['inherit', 'pipe', 'inherit'], 22 | }, 23 | ) 24 | 25 | proc.stdout.setEncoding('utf8') 26 | proc.stdout.on('data', (chunk: string) => { 27 | if (chunk.includes('MCP Streamable HTTP Server listening')) { 28 | res(proc) 29 | } 30 | }) 31 | 32 | proc.on('error', rej) 33 | }) 34 | } 35 | 36 | test.before(async () => { 37 | serverProc = await spawnMcpServer() 38 | }) 39 | 40 | test.after(() => serverProc?.kill('SIGINT')) 41 | 42 | test('streamableHttpToStdio listTools and callTool', async () => { 43 | const gatewayCmd = ['npm', 'run', 'start', '--', '--streamableHttp', MCP_URL] 44 | 45 | const transport = new StdioClientTransport({ 46 | command: gatewayCmd[0], 47 | args: gatewayCmd.slice(1), 48 | }) 49 | 50 | const client = new Client({ name: 'gateway-test', version: '1.0.0' }) 51 | await client.connect(transport) 52 | 53 | const { tools } = await client.listTools() 54 | assert.ok(tools.some((t) => t.name === 'add')) 55 | 56 | type Reply = { content: Array<{ text: string }> } 57 | const reply = (await client.callTool({ 58 | name: 'add', 59 | arguments: { a: 2, b: 3 }, 60 | })) as Reply 61 | 62 | assert.strictEqual(reply.content[0].text, 'The sum of 2 and 3 is 5.') 63 | await client.close() 64 | }) 65 | -------------------------------------------------------------------------------- /src/lib/getLogger.ts: -------------------------------------------------------------------------------- 1 | import util from 'node:util' 2 | import { Logger } from '../types.js' 3 | 4 | const defaultFormatArgs = (args: any[]) => args 5 | 6 | const log = 7 | ( 8 | { 9 | formatArgs = defaultFormatArgs, 10 | }: { 11 | formatArgs?: typeof defaultFormatArgs 12 | } = { formatArgs: defaultFormatArgs }, 13 | ) => 14 | (...args: any[]) => 15 | console.log('[supergateway]', ...formatArgs(args)) 16 | 17 | const logStderr = 18 | ( 19 | { 20 | formatArgs = defaultFormatArgs, 21 | }: { 22 | formatArgs?: typeof defaultFormatArgs 23 | } = { formatArgs: defaultFormatArgs }, 24 | ) => 25 | (...args: any[]) => 26 | console.error('[supergateway]', ...formatArgs(args)) 27 | 28 | const noneLogger: Logger = { 29 | info: () => {}, 30 | error: () => {}, 31 | } 32 | 33 | const infoLogger: Logger = { 34 | info: log(), 35 | error: logStderr(), 36 | } 37 | 38 | const infoLoggerStdio: Logger = { 39 | info: logStderr(), 40 | error: logStderr(), 41 | } 42 | 43 | const debugFormatArgs = (args: any[]) => 44 | args.map((arg) => { 45 | if (typeof arg === 'object') { 46 | return util.inspect(arg, { 47 | depth: null, 48 | colors: process.stderr.isTTY, 49 | compact: false, 50 | }) 51 | } 52 | 53 | return arg 54 | }) 55 | 56 | const debugLogger: Logger = { 57 | info: log({ formatArgs: debugFormatArgs }), 58 | error: logStderr({ formatArgs: debugFormatArgs }), 59 | } 60 | 61 | const debugLoggerStdio: Logger = { 62 | info: logStderr({ formatArgs: debugFormatArgs }), 63 | error: logStderr({ formatArgs: debugFormatArgs }), 64 | } 65 | 66 | export const getLogger = ({ 67 | logLevel, 68 | outputTransport, 69 | }: { 70 | logLevel: string 71 | outputTransport: string 72 | }): Logger => { 73 | if (logLevel === 'none') { 74 | return noneLogger 75 | } 76 | 77 | if (logLevel === 'debug') { 78 | return outputTransport === 'stdio' ? debugLoggerStdio : debugLogger 79 | } 80 | 81 | // info logLevel 82 | return outputTransport === 'stdio' ? infoLoggerStdio : infoLogger 83 | } 84 | -------------------------------------------------------------------------------- /tests/stdioToStatelessStreamableHttp.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { spawn, ChildProcess } from 'child_process' 4 | import { Client } from '@modelcontextprotocol/sdk/client/index.js' 5 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' 6 | 7 | const PORT = 11005 8 | const MCP_URL = `http://localhost:${PORT}/mcp` 9 | 10 | let gatewayProc: ChildProcess 11 | 12 | test.before(() => { 13 | gatewayProc = spawn( 14 | 'npm', 15 | [ 16 | 'run', 17 | 'start', 18 | '--', 19 | '--stdio', 20 | 'node tests/helpers/mock-mcp-server.js stdio', 21 | '--outputTransport', 22 | 'streamableHttp', 23 | '--port', 24 | String(PORT), 25 | '--streamableHttpPath', 26 | '/mcp', 27 | ], 28 | { stdio: 'ignore', shell: false }, 29 | ) 30 | gatewayProc.unref() 31 | }) 32 | 33 | test.after(async () => { 34 | gatewayProc.kill('SIGINT') 35 | await new Promise((resolve) => gatewayProc.once('exit', resolve)) 36 | }) 37 | 38 | test('stdioToStatelessStreamableHttp listTools and callTool', async () => { 39 | const transport = new StreamableHTTPClientTransport(new URL(MCP_URL)) 40 | const client = new Client({ name: 'stateless-test', version: '1.0.0' }) 41 | await new Promise((r) => setTimeout(r, 2000)) 42 | await client.connect(transport) 43 | 44 | assert.strictEqual(transport.sessionId, undefined) 45 | 46 | const { tools } = await client.listTools() 47 | assert.ok(tools.some((t) => t.name === 'add')) 48 | 49 | type Reply = { content: Array<{ text: string }> } 50 | const reply1 = (await client.callTool({ 51 | name: 'add', 52 | arguments: { a: 4, b: 5 }, 53 | })) as Reply 54 | 55 | assert.strictEqual(reply1.content[0].text, 'The sum of 4 and 5 is 9.') 56 | 57 | const reply2 = (await client.callTool({ 58 | name: 'add', 59 | arguments: { a: 2, b: 7 }, 60 | })) as Reply 61 | 62 | assert.strictEqual(reply2.content[0].text, 'The sum of 2 and 7 is 9.') 63 | 64 | await client.close() 65 | transport.close() 66 | }) 67 | 68 | test('GET returns 405', async () => { 69 | const res = await fetch(MCP_URL) 70 | assert.strictEqual(res.status, 405) 71 | }) 72 | -------------------------------------------------------------------------------- /tests/stdioToStatefulStreamableHttp.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { spawn, ChildProcess } from 'child_process' 4 | import { Client } from '@modelcontextprotocol/sdk/client/index.js' 5 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' 6 | 7 | const PORT = 11004 8 | const MCP_URL = `http://localhost:${PORT}/mcp` 9 | 10 | let gatewayProc: ChildProcess 11 | 12 | test.before(() => { 13 | gatewayProc = spawn( 14 | 'npm', 15 | [ 16 | 'run', 17 | 'start', 18 | '--', 19 | '--stdio', 20 | 'node tests/helpers/mock-mcp-server.js stdio', 21 | '--outputTransport', 22 | 'streamableHttp', 23 | '--stateful', 24 | '--port', 25 | String(PORT), 26 | '--streamableHttpPath', 27 | '/mcp', 28 | ], 29 | { stdio: 'ignore', shell: false }, 30 | ) 31 | gatewayProc.unref() 32 | }) 33 | 34 | test.after(async () => { 35 | gatewayProc.kill('SIGINT') 36 | await new Promise((resolve) => gatewayProc.once('exit', resolve)) 37 | }) 38 | 39 | test('stdioToStatefulStreamableHttp listTools and callTool', async () => { 40 | const transport = new StreamableHTTPClientTransport(new URL(MCP_URL)) 41 | const client = new Client({ name: 'stateful-test', version: '1.0.0' }) 42 | await new Promise((r) => setTimeout(r, 2000)) 43 | await client.connect(transport) 44 | 45 | assert.ok(transport.sessionId, 'sessionId should be set after connect') 46 | 47 | const { tools } = await client.listTools() 48 | assert.ok(tools.some((t) => t.name === 'add')) 49 | 50 | type Reply = { content: Array<{ text: string }> } 51 | const reply1 = (await client.callTool({ 52 | name: 'add', 53 | arguments: { a: 1, b: 2 }, 54 | })) as Reply 55 | 56 | assert.strictEqual(reply1.content[0].text, 'The sum of 1 and 2 is 3.') 57 | 58 | const reply2 = (await client.callTool({ 59 | name: 'add', 60 | arguments: { a: 3, b: 4 }, 61 | })) as Reply 62 | 63 | assert.strictEqual(reply2.content[0].text, 'The sum of 3 and 4 is 7.') 64 | 65 | await transport.terminateSession() 66 | assert.strictEqual(transport.sessionId, undefined) 67 | 68 | await client.close() 69 | transport.close() 70 | }) 71 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docker images 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | 9 | permissions: 10 | contents: read 11 | 12 | env: 13 | GHCR_REGISTRY: ghcr.io/supercorp-ai 14 | DOCKERHUB_REGISTRY: docker.io/supercorp 15 | 16 | jobs: 17 | publish: 18 | name: Build and push supergateway container 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: read 22 | id-token: write 23 | packages: write 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v3 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | 36 | - name: Login to GHCR 37 | uses: docker/login-action@v3 38 | with: 39 | registry: ghcr.io 40 | username: ${{ github.repository_owner }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Login to Docker Hub 44 | uses: docker/login-action@v3 45 | with: 46 | registry: docker.io 47 | username: ${{ secrets.DOCKERHUB_USERNAME }} 48 | password: ${{ secrets.DOCKERHUB_TOKEN }} 49 | 50 | - name: Extract version 51 | id: ver 52 | run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" 53 | 54 | - name: Docker meta 55 | id: docker_meta 56 | uses: docker/metadata-action@v5 57 | with: 58 | images: | 59 | ${{ env.GHCR_REGISTRY }}/supergateway 60 | ${{ env.DOCKERHUB_REGISTRY }}/supergateway 61 | labels: | 62 | org.opencontainers.image.title=Supergateway 63 | org.opencontainers.image.version=${{ steps.ver.outputs.VERSION }} 64 | org.opencontainers.image.source=${{ github.repository }} 65 | 66 | - name: Build & push (Bake) 67 | uses: docker/bake-action@v6 68 | env: 69 | VERSION: ${{ steps.ver.outputs.VERSION }} 70 | with: 71 | source: . 72 | files: | 73 | docker-bake.hcl 74 | ${{ steps.docker_meta.outputs.bake-file }} 75 | push: true 76 | set: | 77 | *.args.VERSION=${{ steps.ver.outputs.VERSION }} 78 | *.cache-from=type=gha 79 | *.cache-to=type=gha,mode=max 80 | -------------------------------------------------------------------------------- /tests/helpers/mock-mcp-server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { randomUUID } from 'node:crypto' 3 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 5 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' 6 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' 7 | import { z } from 'zod' 8 | 9 | const mode = process.argv[2] 10 | const port = Number(process.env.PORT || 3000) 11 | 12 | const server = new McpServer({ name: 'mock-server', version: '1.0.0' }) 13 | 14 | server.tool('add', { a: z.number(), b: z.number() }, async ({ a, b }) => ({ 15 | content: [{ type: 'text', text: `The sum of ${a} and ${b} is ${a + b}.` }], 16 | })) 17 | 18 | if (mode === 'stdio') { 19 | const transport = new StdioServerTransport() 20 | await server.connect(transport) 21 | } else if (mode === 'sse') { 22 | const app = express() 23 | app.use(express.json()) 24 | const transports = {} 25 | 26 | app.get('/sse', async (req, res) => { 27 | const transport = new SSEServerTransport('/message', res) 28 | const sid = transport.sessionId 29 | transports[sid] = transport 30 | transport.onclose = () => { 31 | delete transports[sid] 32 | } 33 | await server.connect(transport) 34 | }) 35 | 36 | app.post('/message', async (req, res) => { 37 | const sessionId = req.query.sessionId 38 | const transport = transports[sessionId] 39 | if (!transport) { 40 | res.status(404).send('Session not found') 41 | return 42 | } 43 | await transport.handlePostMessage(req, res, req.body) 44 | }) 45 | 46 | app.listen(port, () => { 47 | console.log(`Server is running on port ${port}`) 48 | }) 49 | } else if (mode === 'streamableHttp') { 50 | const app = express() 51 | app.use(express.json()) 52 | 53 | const transports = {} 54 | 55 | app.all('/mcp', async (req, res) => { 56 | const sessionId = req.headers['mcp-session-id'] 57 | let transport = sessionId ? transports[sessionId] : undefined 58 | 59 | if (!transport) { 60 | if (req.method === 'POST' && req.body?.method === 'initialize') { 61 | transport = new StreamableHTTPServerTransport({ 62 | sessionIdGenerator: () => randomUUID(), 63 | onsessioninitialized: (sid) => { 64 | transports[sid] = transport 65 | }, 66 | }) 67 | transport.onclose = () => { 68 | const sid = transport.sessionId 69 | if (sid) delete transports[sid] 70 | } 71 | await server.connect(transport) 72 | } else { 73 | res.status(400).json({ 74 | jsonrpc: '2.0', 75 | error: { 76 | code: -32000, 77 | message: 'Bad Request: Server not initialized', 78 | }, 79 | id: null, 80 | }) 81 | return 82 | } 83 | } 84 | 85 | await transport.handleRequest(req, res, req.body) 86 | }) 87 | 88 | app.listen(port, () => { 89 | console.log(`MCP Streamable HTTP Server listening on port ${port}`) 90 | }) 91 | } else { 92 | console.error('Unknown mode: ' + mode) 93 | process.exit(1) 94 | } 95 | -------------------------------------------------------------------------------- /src/lib/sessionAccessCounter.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../types.js' 2 | 3 | export class SessionAccessCounter { 4 | private sessions: Map< 5 | string, 6 | { accessCount: number } | { timeout: NodeJS.Timeout } 7 | > = new Map() 8 | 9 | constructor( 10 | public timeout: number, 11 | public cleanup: (sessionId: string) => unknown, 12 | public logger: Logger, 13 | ) {} 14 | 15 | inc(sessionId: string, reason: string) { 16 | this.logger.info( 17 | `SessionAccessCounter.inc() ${sessionId}, caused by ${reason}`, 18 | ) 19 | 20 | const session = this.sessions.get(sessionId) 21 | 22 | if (!session) { 23 | // New session 24 | this.logger.info( 25 | `Session access count 0 -> 1 for ${sessionId} (new session)`, 26 | ) 27 | this.sessions.set(sessionId, { accessCount: 1 }) 28 | return 29 | } 30 | 31 | if ('timeout' in session) { 32 | // Clear pending cleanup and reactivate 33 | this.logger.info( 34 | `Session access count 0 -> 1, clearing cleanup timeout for ${sessionId}`, 35 | ) 36 | clearTimeout(session.timeout) 37 | this.sessions.set(sessionId, { accessCount: 1 }) 38 | } else { 39 | // Increment active session 40 | this.logger.info( 41 | `Session access count ${session.accessCount} -> ${session.accessCount + 1} for ${sessionId}`, 42 | ) 43 | session.accessCount++ 44 | } 45 | } 46 | 47 | dec(sessionId: string, reason: string) { 48 | this.logger.info( 49 | `SessionAccessCounter.dec() ${sessionId}, caused by ${reason}`, 50 | ) 51 | 52 | const session = this.sessions.get(sessionId) 53 | 54 | if (!session) { 55 | this.logger.error( 56 | `Called dec() on non-existent session ${sessionId}, ignoring`, 57 | ) 58 | return 59 | } 60 | 61 | if ('timeout' in session) { 62 | this.logger.error( 63 | `Called dec() on session ${sessionId} that is already pending cleanup, ignoring`, 64 | ) 65 | return 66 | } 67 | 68 | if (session.accessCount <= 0) { 69 | throw new Error( 70 | `Invalid access count ${session.accessCount} for session ${sessionId}`, 71 | ) 72 | } 73 | 74 | session.accessCount-- 75 | this.logger.info( 76 | `Session access count ${session.accessCount + 1} -> ${session.accessCount} for ${sessionId}`, 77 | ) 78 | 79 | if (session.accessCount === 0) { 80 | this.logger.info( 81 | `Session access count reached 0, setting cleanup timeout for ${sessionId}`, 82 | ) 83 | 84 | this.sessions.set(sessionId, { 85 | timeout: setTimeout(() => { 86 | this.logger.info(`Session ${sessionId} timed out, cleaning up`) 87 | this.sessions.delete(sessionId) 88 | this.cleanup(sessionId) 89 | }, this.timeout), 90 | }) 91 | } 92 | } 93 | 94 | clear(sessionId: string, runCleanup: boolean, reason: string) { 95 | this.logger.info( 96 | `SessionAccessCounter.clear() ${sessionId}, caused by ${reason}`, 97 | ) 98 | 99 | const session = this.sessions.get(sessionId) 100 | if (!session) { 101 | this.logger.info(`Attempted to clear non-existent session ${sessionId}`) 102 | return 103 | } 104 | 105 | // Clear any pending timeout 106 | if ('timeout' in session) { 107 | clearTimeout(session.timeout) 108 | } 109 | 110 | // Remove from tracking 111 | this.sessions.delete(sessionId) 112 | 113 | // Run cleanup if requested 114 | if (runCleanup) { 115 | this.cleanup(sessionId) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/server/websocket.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Transport, 3 | TransportSendOptions, 4 | } from '@modelcontextprotocol/sdk/shared/transport.js' 5 | import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' 6 | import { v4 as uuidv4 } from 'uuid' 7 | import { WebSocket, WebSocketServer } from 'ws' 8 | import { Server } from 'http' 9 | 10 | export class WebSocketServerTransport implements Transport { 11 | private wss!: WebSocketServer 12 | private clients: Map = new Map() 13 | 14 | onclose?: () => void 15 | onerror?: (err: Error) => void 16 | private messageHandler?: (msg: JSONRPCMessage, clientId: string) => void 17 | onconnection?: (clientId: string) => void 18 | ondisconnection?: (clientId: string) => void 19 | 20 | set onmessage(handler: ((message: JSONRPCMessage) => void) | undefined) { 21 | this.messageHandler = handler 22 | ? (msg, clientId) => { 23 | // @ts-ignore 24 | if (msg.id === undefined) { 25 | console.log('Broadcast message:', msg) 26 | return handler(msg) 27 | } 28 | // @ts-ignore 29 | return handler({ 30 | ...msg, 31 | // @ts-ignore 32 | id: clientId + ':' + msg.id, 33 | }) 34 | } 35 | : undefined 36 | } 37 | 38 | constructor({ path, server }: { path: string; server: Server }) { 39 | this.wss = new WebSocketServer({ 40 | path, 41 | server, 42 | }) 43 | } 44 | 45 | async start(): Promise { 46 | this.wss.on('connection', (ws: WebSocket) => { 47 | const clientId = uuidv4() 48 | this.clients.set(clientId, ws) 49 | this.onconnection?.(clientId) 50 | 51 | ws.on('message', (data: Buffer) => { 52 | try { 53 | const msg = JSON.parse(data.toString()) 54 | this.messageHandler?.(msg, clientId) 55 | } catch (err) { 56 | this.onerror?.(new Error(`Failed to parse message: ${err}`)) 57 | } 58 | }) 59 | 60 | ws.on('close', () => { 61 | this.clients.delete(clientId) 62 | this.ondisconnection?.(clientId) 63 | }) 64 | 65 | ws.on('error', (err: Error) => { 66 | this.onerror?.(err) 67 | }) 68 | }) 69 | } 70 | 71 | async send( 72 | msg: JSONRPCMessage, 73 | options?: TransportSendOptions | string, 74 | ): Promise { 75 | // decide if they passed a raw clientId (legacy) or options object 76 | const clientId = typeof options === 'string' ? options : undefined 77 | 78 | // if your protocol mangles IDs to include clientId, strip it off 79 | const [cId, rawId] = clientId?.split(':') ?? [] 80 | if (rawId !== undefined) { 81 | // @ts-ignore 82 | msg.id = parseInt(rawId, 10) 83 | } 84 | 85 | const payload = JSON.stringify(msg) 86 | 87 | if (cId) { 88 | // send only to the one client 89 | const ws = this.clients.get(cId) 90 | if (ws?.readyState === WebSocket.OPEN) { 91 | ws.send(payload) 92 | } else { 93 | this.clients.delete(cId) 94 | this.ondisconnection?.(cId) 95 | } 96 | } else { 97 | // broadcast to everyone 98 | for (const [id, ws] of this.clients) { 99 | if (ws.readyState === WebSocket.OPEN) { 100 | ws.send(payload) 101 | } else { 102 | this.clients.delete(id) 103 | this.ondisconnection?.(id) 104 | } 105 | } 106 | } 107 | } 108 | 109 | async broadcast(msg: JSONRPCMessage): Promise { 110 | return this.send(msg) 111 | } 112 | 113 | async close(): Promise { 114 | return new Promise((resolve) => { 115 | this.wss.close(() => { 116 | this.clients.clear() 117 | resolve() 118 | }) 119 | }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/concurrency.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test' 2 | import assert from 'node:assert/strict' 3 | import { randomInt } from 'node:crypto' 4 | import { performance } from 'node:perf_hooks' 5 | import { spawn, ChildProcess } from 'child_process' 6 | import { Client } from '@modelcontextprotocol/sdk/client/index.js' 7 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' 8 | 9 | const BASE_URL = 'http://localhost:11001' 10 | const SSE_PATH = '/sse' 11 | const CONCURRENCY = 1 12 | 13 | function makeLimiter(maxConcurrency: number) { 14 | let active = 0 15 | const queue: (() => void)[] = [] 16 | 17 | return async function (fn: () => Promise): Promise { 18 | if (active >= maxConcurrency) { 19 | // wait for a slot 20 | await new Promise((res) => queue.push(res)) 21 | } 22 | active++ 23 | try { 24 | return await fn() 25 | } finally { 26 | active-- 27 | // free up next waiter 28 | const next = queue.shift() 29 | if (next) next() 30 | } 31 | } 32 | } 33 | 34 | const limit = makeLimiter(CONCURRENCY) 35 | 36 | let gatewayProc: ChildProcess 37 | 38 | test.before(async () => { 39 | gatewayProc = spawn( 40 | 'npm', 41 | [ 42 | 'run', 43 | 'start', 44 | '--', 45 | '--stdio', 46 | 'node tests/helpers/mock-mcp-server.js stdio', 47 | '--outputTransport', 48 | 'sse', 49 | '--port', 50 | '11001', 51 | '--baseUrl', 52 | BASE_URL, 53 | '--ssePath', 54 | SSE_PATH, 55 | '--messagePath', 56 | '/message', 57 | ], 58 | { stdio: 'ignore', shell: false }, 59 | ) 60 | 61 | gatewayProc.unref() 62 | 63 | await new Promise((resolve) => setTimeout(resolve, 2000)) 64 | }) 65 | 66 | test.after(async () => { 67 | gatewayProc.kill('SIGINT') 68 | await new Promise((resolve) => gatewayProc.once('exit', resolve)) 69 | }) 70 | 71 | test('concurrent listTools → callTool', async () => { 72 | const succeededInstances: { id: number; text: string }[] = [] 73 | 74 | const runClient = async (id: number) => { 75 | const headers = { 76 | Authorization: 'Bearer YOUR_API_KEY', 77 | 'X-Instance-ID': String(id), 78 | } 79 | 80 | /** helper wrapper so TS sees correct `(input, init?)` signature */ 81 | const fetchWithHeaders = 82 | (hdrs: Record) => 83 | (input: RequestInfo | URL, init: RequestInit = {}) => 84 | fetch(input, { ...init, headers: { ...init.headers, ...hdrs } }) 85 | 86 | const transport = new SSEClientTransport(new URL(SSE_PATH, BASE_URL), { 87 | eventSourceInit: { fetch: fetchWithHeaders(headers) }, 88 | requestInit: { headers }, 89 | }) 90 | 91 | const client = new Client({ name: `load-${id}`, version: '0.0.0' }) 92 | 93 | const timing: Record = {} 94 | const span = async (label: string, fn: () => Promise) => { 95 | const t0 = performance.now() 96 | const out = await fn() 97 | timing[label] = performance.now() - t0 98 | return out 99 | } 100 | 101 | await client.connect(transport) 102 | 103 | const tools = await span('listTools', () => client.listTools()) 104 | assert.ok(Array.isArray(tools.tools), 'listTools() must return array') 105 | 106 | const rnd = randomInt(1, 51) 107 | const reply = await span('add', () => 108 | client.callTool({ name: 'add', arguments: { a: id, b: rnd } }, undefined), 109 | ) 110 | const content = reply.content as any 111 | const text = content && content[0]?.text 112 | console.log({ text }) 113 | assert.strictEqual(text, `The sum of ${id} and ${rnd} is ${id + rnd}.`) 114 | 115 | await client.close() 116 | transport.close() 117 | console.log(`Instance ${id} timings:`, timing) 118 | succeededInstances.push({ 119 | id, 120 | text, 121 | }) 122 | } 123 | 124 | await Promise.all( 125 | Array.from({ length: CONCURRENCY }, (_, i) => 126 | limit(() => runClient(i + 1)), 127 | ), 128 | ) 129 | 130 | assert.strictEqual( 131 | succeededInstances.length, 132 | CONCURRENCY, 133 | 'All instances should succeed', 134 | ) 135 | 136 | console.log({ succeededInstances }) 137 | }) 138 | -------------------------------------------------------------------------------- /src/gateways/stdioToWs.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import cors, { type CorsOptions } from 'cors' 3 | import { createServer } from 'http' 4 | import { spawn, ChildProcessWithoutNullStreams } from 'child_process' 5 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 6 | import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' 7 | import { Logger } from '../types.js' 8 | import { getVersion } from '../lib/getVersion.js' 9 | import { WebSocketServerTransport } from '../server/websocket.js' 10 | import { onSignals } from '../lib/onSignals.js' 11 | import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js' 12 | 13 | export interface StdioToWsArgs { 14 | stdioCmd: string 15 | port: number 16 | messagePath: string 17 | logger: Logger 18 | corsOrigin: CorsOptions['origin'] 19 | healthEndpoints: string[] 20 | } 21 | 22 | export async function stdioToWs(args: StdioToWsArgs) { 23 | const { stdioCmd, port, messagePath, logger, healthEndpoints, corsOrigin } = 24 | args 25 | logger.info(` - port: ${port}`) 26 | logger.info(` - stdio: ${stdioCmd}`) 27 | logger.info(` - messagePath: ${messagePath}`) 28 | logger.info( 29 | ` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`, 30 | ) 31 | logger.info( 32 | ` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`, 33 | ) 34 | 35 | let wsTransport: WebSocketServerTransport | null = null 36 | let child: ChildProcessWithoutNullStreams | null = null 37 | let isReady = false 38 | 39 | const cleanup = () => { 40 | if (wsTransport) { 41 | wsTransport.close().catch((err) => { 42 | logger.error(`Error stopping WebSocket server: ${err.message}`) 43 | }) 44 | } 45 | if (child) { 46 | child.kill() 47 | } 48 | } 49 | 50 | onSignals({ 51 | logger, 52 | cleanup, 53 | }) 54 | 55 | try { 56 | child = spawn(stdioCmd, { shell: true }) 57 | child.on('exit', (code, signal) => { 58 | logger.error(`Child exited: code=${code}, signal=${signal}`) 59 | cleanup() 60 | process.exit(code ?? 1) 61 | }) 62 | 63 | const server = new Server( 64 | { name: 'supergateway', version: getVersion() }, 65 | { capabilities: {} }, 66 | ) 67 | 68 | // Handle child process output 69 | let buffer = '' 70 | child.stdout.on('data', (chunk: Buffer) => { 71 | buffer += chunk.toString('utf8') 72 | const lines = buffer.split(/\r?\n/) 73 | buffer = lines.pop() ?? '' 74 | lines.forEach((line) => { 75 | if (!line.trim()) return 76 | try { 77 | const jsonMsg = JSON.parse(line) 78 | logger.info(`Child → WebSocket: ${JSON.stringify(jsonMsg)}`) 79 | // Broadcast to all connected clients 80 | wsTransport?.send(jsonMsg, jsonMsg.id).catch((err) => { 81 | logger.error('Failed to broadcast message:', err) 82 | }) 83 | } catch { 84 | logger.error(`Child non-JSON: ${line}`) 85 | } 86 | }) 87 | }) 88 | 89 | child.stderr.on('data', (chunk: Buffer) => { 90 | logger.info(`Child stderr: ${chunk.toString('utf8')}`) 91 | }) 92 | 93 | const app = express() 94 | 95 | if (corsOrigin) { 96 | app.use(cors({ origin: corsOrigin })) 97 | } 98 | 99 | for (const ep of healthEndpoints) { 100 | app.get(ep, (_req, res) => { 101 | if (child?.killed) { 102 | res.status(500).send('Child process has been killed') 103 | } 104 | 105 | if (!isReady) { 106 | res.status(500).send('Server is not ready') 107 | } 108 | 109 | res.send('ok') 110 | }) 111 | } 112 | 113 | const httpServer = createServer(app) 114 | 115 | wsTransport = new WebSocketServerTransport({ 116 | path: messagePath, 117 | server: httpServer, 118 | }) 119 | 120 | await server.connect(wsTransport) 121 | 122 | wsTransport.onmessage = (msg: JSONRPCMessage) => { 123 | const line = JSON.stringify(msg) 124 | logger.info(`WebSocket → Child: ${line}`) 125 | child!.stdin.write(line + '\n') 126 | } 127 | 128 | wsTransport.onconnection = (clientId: string) => { 129 | logger.info(`New WebSocket connection: ${clientId}`) 130 | } 131 | 132 | wsTransport.ondisconnection = (clientId: string) => { 133 | logger.info(`WebSocket connection closed: ${clientId}`) 134 | } 135 | 136 | wsTransport.onerror = (err: Error) => { 137 | logger.error(`WebSocket error: ${err.message}`) 138 | } 139 | 140 | isReady = true 141 | 142 | httpServer.listen(port, () => { 143 | logger.info(`Listening on port ${port}`) 144 | logger.info(`WebSocket endpoint: ws://localhost:${port}${messagePath}`) 145 | }) 146 | } catch (err: any) { 147 | logger.error(`Failed to start: ${err.message}`) 148 | cleanup() 149 | process.exit(1) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/gateways/stdioToSse.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import bodyParser from 'body-parser' 3 | import cors, { type CorsOptions } from 'cors' 4 | import { spawn, ChildProcessWithoutNullStreams } from 'child_process' 5 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 6 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js' 7 | import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' 8 | import { Logger } from '../types.js' 9 | import { getVersion } from '../lib/getVersion.js' 10 | import { onSignals } from '../lib/onSignals.js' 11 | import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js' 12 | 13 | export interface StdioToSseArgs { 14 | stdioCmd: string 15 | port: number 16 | baseUrl: string 17 | ssePath: string 18 | messagePath: string 19 | logger: Logger 20 | corsOrigin: CorsOptions['origin'] 21 | healthEndpoints: string[] 22 | headers: Record 23 | } 24 | 25 | const setResponseHeaders = ({ 26 | res, 27 | headers, 28 | }: { 29 | res: express.Response 30 | headers: Record 31 | }) => 32 | Object.entries(headers).forEach(([key, value]) => { 33 | res.setHeader(key, value) 34 | }) 35 | 36 | export async function stdioToSse(args: StdioToSseArgs) { 37 | const { 38 | stdioCmd, 39 | port, 40 | baseUrl, 41 | ssePath, 42 | messagePath, 43 | logger, 44 | corsOrigin, 45 | healthEndpoints, 46 | headers, 47 | } = args 48 | 49 | logger.info( 50 | ` - Headers: ${Object(headers).length ? JSON.stringify(headers) : '(none)'}`, 51 | ) 52 | logger.info(` - port: ${port}`) 53 | logger.info(` - stdio: ${stdioCmd}`) 54 | if (baseUrl) { 55 | logger.info(` - baseUrl: ${baseUrl}`) 56 | } 57 | logger.info(` - ssePath: ${ssePath}`) 58 | logger.info(` - messagePath: ${messagePath}`) 59 | 60 | logger.info( 61 | ` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`, 62 | ) 63 | logger.info( 64 | ` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`, 65 | ) 66 | 67 | onSignals({ logger }) 68 | 69 | const child: ChildProcessWithoutNullStreams = spawn(stdioCmd, { shell: true }) 70 | child.on('exit', (code, signal) => { 71 | logger.error(`Child exited: code=${code}, signal=${signal}`) 72 | process.exit(code ?? 1) 73 | }) 74 | 75 | const server = new Server( 76 | { name: 'supergateway', version: getVersion() }, 77 | { capabilities: {} }, 78 | ) 79 | 80 | const sessions: Record< 81 | string, 82 | { transport: SSEServerTransport; response: express.Response } 83 | > = {} 84 | 85 | const app = express() 86 | 87 | if (corsOrigin) { 88 | app.use(cors({ origin: corsOrigin })) 89 | } 90 | 91 | app.use((req, res, next) => { 92 | if (req.path === messagePath) return next() 93 | return bodyParser.json()(req, res, next) 94 | }) 95 | 96 | for (const ep of healthEndpoints) { 97 | app.get(ep, (_req, res) => { 98 | setResponseHeaders({ 99 | res, 100 | headers, 101 | }) 102 | res.send('ok') 103 | }) 104 | } 105 | 106 | app.get(ssePath, async (req, res) => { 107 | logger.info(`New SSE connection from ${req.ip}`) 108 | 109 | setResponseHeaders({ 110 | res, 111 | headers, 112 | }) 113 | 114 | const sseTransport = new SSEServerTransport(`${baseUrl}${messagePath}`, res) 115 | await server.connect(sseTransport) 116 | 117 | const sessionId = sseTransport.sessionId 118 | if (sessionId) { 119 | sessions[sessionId] = { transport: sseTransport, response: res } 120 | } 121 | 122 | sseTransport.onmessage = (msg: JSONRPCMessage) => { 123 | logger.info(`SSE → Child (session ${sessionId}): ${JSON.stringify(msg)}`) 124 | child.stdin.write(JSON.stringify(msg) + '\n') 125 | } 126 | 127 | sseTransport.onclose = () => { 128 | logger.info(`SSE connection closed (session ${sessionId})`) 129 | delete sessions[sessionId] 130 | } 131 | 132 | sseTransport.onerror = (err) => { 133 | logger.error(`SSE error (session ${sessionId}):`, err) 134 | delete sessions[sessionId] 135 | } 136 | 137 | req.on('close', () => { 138 | logger.info(`Client disconnected (session ${sessionId})`) 139 | delete sessions[sessionId] 140 | }) 141 | }) 142 | 143 | // @ts-ignore 144 | app.post(messagePath, async (req, res) => { 145 | const sessionId = req.query.sessionId as string 146 | 147 | setResponseHeaders({ 148 | res, 149 | headers, 150 | }) 151 | 152 | if (!sessionId) { 153 | return res.status(400).send('Missing sessionId parameter') 154 | } 155 | 156 | const session = sessions[sessionId] 157 | if (session?.transport?.handlePostMessage) { 158 | logger.info(`POST to SSE transport (session ${sessionId})`) 159 | await session.transport.handlePostMessage(req, res) 160 | } else { 161 | res.status(503).send(`No active SSE connection for session ${sessionId}`) 162 | } 163 | }) 164 | 165 | app.listen(port, () => { 166 | logger.info(`Listening on port ${port}`) 167 | logger.info(`SSE endpoint: http://localhost:${port}${ssePath}`) 168 | logger.info(`POST messages: http://localhost:${port}${messagePath}`) 169 | }) 170 | 171 | let buffer = '' 172 | child.stdout.on('data', (chunk: Buffer) => { 173 | buffer += chunk.toString('utf8') 174 | const lines = buffer.split(/\r?\n/) 175 | buffer = lines.pop() ?? '' 176 | lines.forEach((line) => { 177 | if (!line.trim()) return 178 | try { 179 | const jsonMsg = JSON.parse(line) 180 | logger.info('Child → SSE:', jsonMsg) 181 | for (const [sid, session] of Object.entries(sessions)) { 182 | try { 183 | session.transport.send(jsonMsg) 184 | } catch (err) { 185 | logger.error(`Failed to send to session ${sid}:`, err) 186 | delete sessions[sid] 187 | } 188 | } 189 | } catch { 190 | logger.error(`Child non-JSON: ${line}`) 191 | } 192 | }) 193 | }) 194 | 195 | child.stderr.on('data', (chunk: Buffer) => { 196 | logger.error(`Child stderr: ${chunk.toString('utf8')}`) 197 | }) 198 | } 199 | -------------------------------------------------------------------------------- /src/gateways/sseToStdio.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js' 2 | import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 5 | import type { 6 | JSONRPCMessage, 7 | JSONRPCRequest, 8 | ClientCapabilities, 9 | Implementation, 10 | } from '@modelcontextprotocol/sdk/types.js' 11 | import { z } from 'zod' 12 | import { getVersion } from '../lib/getVersion.js' 13 | import { Logger } from '../types.js' 14 | import { onSignals } from '../lib/onSignals.js' 15 | 16 | export interface SseToStdioArgs { 17 | sseUrl: string 18 | logger: Logger 19 | headers: Record 20 | } 21 | 22 | let sseClient: Client | undefined 23 | 24 | const newInitializeSseClient = ({ message }: { message: JSONRPCRequest }) => { 25 | const clientInfo = message.params?.clientInfo as Implementation | undefined 26 | const clientCapabilities = message.params?.capabilities as 27 | | ClientCapabilities 28 | | undefined 29 | 30 | return new Client( 31 | { 32 | name: clientInfo?.name ?? 'supergateway', 33 | version: clientInfo?.version ?? getVersion(), 34 | }, 35 | { 36 | capabilities: clientCapabilities ?? {}, 37 | }, 38 | ) 39 | } 40 | 41 | const newFallbackSseClient = async ({ 42 | sseTransport, 43 | }: { 44 | sseTransport: SSEClientTransport 45 | }) => { 46 | const fallbackSseClient = new Client( 47 | { 48 | name: 'supergateway', 49 | version: getVersion(), 50 | }, 51 | { 52 | capabilities: {}, 53 | }, 54 | ) 55 | 56 | await fallbackSseClient.connect(sseTransport) 57 | return fallbackSseClient 58 | } 59 | 60 | export async function sseToStdio(args: SseToStdioArgs) { 61 | const { sseUrl, logger, headers } = args 62 | 63 | logger.info(` - sse: ${sseUrl}`) 64 | logger.info( 65 | ` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`, 66 | ) 67 | logger.info('Connecting to SSE...') 68 | 69 | onSignals({ logger }) 70 | 71 | const sseTransport = new SSEClientTransport(new URL(sseUrl), { 72 | eventSourceInit: { 73 | fetch: (...props: Parameters) => { 74 | const [url, init = {}] = props 75 | return fetch(url, { ...init, headers: { ...init.headers, ...headers } }) 76 | }, 77 | }, 78 | requestInit: { 79 | headers, 80 | }, 81 | }) 82 | 83 | sseTransport.onerror = (err) => { 84 | logger.error('SSE error:', err) 85 | } 86 | 87 | sseTransport.onclose = () => { 88 | logger.error('SSE connection closed') 89 | process.exit(1) 90 | } 91 | 92 | const stdioServer = new Server( 93 | { 94 | name: 'supergateway', 95 | version: getVersion(), 96 | }, 97 | { 98 | capabilities: {}, 99 | }, 100 | ) 101 | 102 | const stdioTransport = new StdioServerTransport() 103 | await stdioServer.connect(stdioTransport) 104 | 105 | const wrapResponse = (req: JSONRPCRequest, payload: object) => ({ 106 | jsonrpc: req.jsonrpc || '2.0', 107 | id: req.id, 108 | ...payload, 109 | }) 110 | 111 | stdioServer.transport!.onmessage = async (message: JSONRPCMessage) => { 112 | const isRequest = 'method' in message && 'id' in message 113 | if (isRequest) { 114 | logger.info('Stdio → SSE:', message) 115 | const req = message as JSONRPCRequest 116 | let result 117 | 118 | try { 119 | if (!sseClient) { 120 | if (message.method === 'initialize') { 121 | sseClient = newInitializeSseClient({ 122 | message, 123 | }) 124 | 125 | const originalRequest = sseClient.request 126 | 127 | sseClient.request = async function (requestMessage, ...restArgs) { 128 | // pass protocol version from original client 129 | if ( 130 | requestMessage.method === 'initialize' && 131 | message.params?.protocolVersion && 132 | requestMessage.params?.protocolVersion 133 | ) { 134 | requestMessage.params.protocolVersion = 135 | message.params.protocolVersion 136 | } 137 | 138 | result = await originalRequest.apply(this, [ 139 | requestMessage, 140 | ...restArgs, 141 | ]) 142 | 143 | return result 144 | } 145 | 146 | await sseClient.connect(sseTransport) 147 | sseClient.request = originalRequest 148 | } else { 149 | logger.info('SSE client not initialized, creating fallback client') 150 | sseClient = await newFallbackSseClient({ sseTransport }) 151 | } 152 | 153 | logger.info('SSE connected') 154 | } else { 155 | result = await sseClient.request(req, z.any()) 156 | } 157 | } catch (err) { 158 | logger.error('Request error:', err) 159 | const errorCode = 160 | err && typeof err === 'object' && 'code' in err 161 | ? (err as any).code 162 | : -32000 163 | let errorMsg = 164 | err && typeof err === 'object' && 'message' in err 165 | ? (err as any).message 166 | : 'Internal error' 167 | const prefix = `MCP error ${errorCode}:` 168 | if (errorMsg.startsWith(prefix)) { 169 | errorMsg = errorMsg.slice(prefix.length).trim() 170 | } 171 | const errorResp = wrapResponse(req, { 172 | error: { 173 | code: errorCode, 174 | message: errorMsg, 175 | }, 176 | }) 177 | process.stdout.write(JSON.stringify(errorResp) + '\n') 178 | return 179 | } 180 | const response = wrapResponse( 181 | req, 182 | result.hasOwnProperty('error') 183 | ? { error: { ...result.error } } 184 | : { result: { ...result } }, 185 | ) 186 | logger.info('Response:', response) 187 | process.stdout.write(JSON.stringify(response) + '\n') 188 | } else { 189 | logger.info('SSE → Stdio:', message) 190 | process.stdout.write(JSON.stringify(message) + '\n') 191 | } 192 | } 193 | 194 | logger.info('Stdio server listening') 195 | } 196 | -------------------------------------------------------------------------------- /src/gateways/streamableHttpToStdio.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js' 2 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' 5 | import type { 6 | JSONRPCMessage, 7 | JSONRPCRequest, 8 | ClientCapabilities, 9 | Implementation, 10 | } from '@modelcontextprotocol/sdk/types.js' 11 | import { InitializeRequestSchema } from '@modelcontextprotocol/sdk/types.js' 12 | import { z } from 'zod' 13 | import { getVersion } from '../lib/getVersion.js' 14 | import { Logger } from '../types.js' 15 | import { onSignals } from '../lib/onSignals.js' 16 | 17 | export interface StreamableHttpToStdioArgs { 18 | streamableHttpUrl: string 19 | logger: Logger 20 | headers: Record 21 | } 22 | 23 | let mcpClient: Client | undefined 24 | 25 | const newInitializeMcpClient = ({ message }: { message: JSONRPCRequest }) => { 26 | const clientInfo = message.params?.clientInfo as Implementation | undefined 27 | const clientCapabilities = message.params?.capabilities as 28 | | ClientCapabilities 29 | | undefined 30 | 31 | return new Client( 32 | { 33 | name: clientInfo?.name ?? 'supergateway', 34 | version: clientInfo?.version ?? getVersion(), 35 | }, 36 | { 37 | capabilities: clientCapabilities ?? {}, 38 | }, 39 | ) 40 | } 41 | 42 | const newFallbackMcpClient = async ({ 43 | mcpTransport, 44 | }: { 45 | mcpTransport: StreamableHTTPClientTransport 46 | }) => { 47 | const fallbackMcpClient = new Client( 48 | { 49 | name: 'supergateway', 50 | version: getVersion(), 51 | }, 52 | { 53 | capabilities: {}, 54 | }, 55 | ) 56 | 57 | await fallbackMcpClient.connect(mcpTransport) 58 | return fallbackMcpClient 59 | } 60 | 61 | export async function streamableHttpToStdio(args: StreamableHttpToStdioArgs) { 62 | const { streamableHttpUrl, logger, headers } = args 63 | 64 | logger.info(` - streamableHttp: ${streamableHttpUrl}`) 65 | logger.info( 66 | ` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`, 67 | ) 68 | logger.info('Connecting to Streamable HTTP...') 69 | 70 | onSignals({ logger }) 71 | 72 | const mcpTransport = new StreamableHTTPClientTransport( 73 | new URL(streamableHttpUrl), 74 | { 75 | requestInit: { 76 | headers, 77 | }, 78 | }, 79 | ) 80 | 81 | mcpTransport.onerror = (err) => { 82 | logger.error('Streamable HTTP error:', err) 83 | } 84 | 85 | mcpTransport.onclose = () => { 86 | logger.error('Streamable HTTP connection closed') 87 | process.exit(1) 88 | } 89 | 90 | const stdioServer = new Server( 91 | { 92 | name: 'supergateway', 93 | version: getVersion(), 94 | }, 95 | { 96 | capabilities: {}, 97 | }, 98 | ) 99 | 100 | const stdioTransport = new StdioServerTransport() 101 | await stdioServer.connect(stdioTransport) 102 | 103 | const wrapResponse = (req: JSONRPCRequest, payload: object) => ({ 104 | jsonrpc: req.jsonrpc || '2.0', 105 | id: req.id, 106 | ...payload, 107 | }) 108 | 109 | stdioServer.transport!.onmessage = async (message: JSONRPCMessage) => { 110 | const isRequest = 'method' in message && 'id' in message 111 | if (isRequest) { 112 | logger.info('Stdio → Streamable HTTP:', message) 113 | const req = message as JSONRPCRequest 114 | let result 115 | 116 | try { 117 | if (!mcpClient) { 118 | if (message.method === 'initialize') { 119 | mcpClient = newInitializeMcpClient({ 120 | message, 121 | }) 122 | 123 | const originalRequest = mcpClient.request 124 | 125 | mcpClient.request = async function ( 126 | possibleInitRequestMessage, 127 | ...restArgs 128 | ) { 129 | if ( 130 | InitializeRequestSchema.safeParse(possibleInitRequestMessage) 131 | .success && 132 | message.params?.protocolVersion 133 | ) { 134 | // respect the protocol version from the stdio client's init request 135 | possibleInitRequestMessage.params!.protocolVersion = 136 | message.params.protocolVersion 137 | } 138 | result = await originalRequest.apply(this, [ 139 | possibleInitRequestMessage, 140 | ...restArgs, 141 | ]) 142 | return result 143 | } 144 | 145 | await mcpClient.connect(mcpTransport) 146 | mcpClient.request = originalRequest 147 | } else { 148 | logger.info( 149 | 'Streamable HTTP client not initialized, creating fallback client', 150 | ) 151 | mcpClient = await newFallbackMcpClient({ mcpTransport }) 152 | } 153 | 154 | logger.info('Streamable HTTP connected') 155 | } else { 156 | result = await mcpClient.request(req, z.any()) 157 | } 158 | } catch (err) { 159 | logger.error('Request error:', err) 160 | const errorCode = 161 | err && typeof err === 'object' && 'code' in err 162 | ? (err as any).code 163 | : -32000 164 | let errorMsg = 165 | err && typeof err === 'object' && 'message' in err 166 | ? (err as any).message 167 | : 'Internal error' 168 | const prefix = `MCP error ${errorCode}:` 169 | if (errorMsg.startsWith(prefix)) { 170 | errorMsg = errorMsg.slice(prefix.length).trim() 171 | } 172 | const errorResp = wrapResponse(req, { 173 | error: { 174 | code: errorCode, 175 | message: errorMsg, 176 | }, 177 | }) 178 | process.stdout.write(JSON.stringify(errorResp) + '\n') 179 | return 180 | } 181 | const response = wrapResponse( 182 | req, 183 | result.hasOwnProperty('error') 184 | ? { error: { ...result.error } } 185 | : { result: { ...result } }, 186 | ) 187 | logger.info('Response:', response) 188 | process.stdout.write(JSON.stringify(response) + '\n') 189 | } else { 190 | logger.info('Streamable HTTP → Stdio:', message) 191 | process.stdout.write(JSON.stringify(message) + '\n') 192 | } 193 | } 194 | 195 | logger.info('Stdio server listening') 196 | } 197 | -------------------------------------------------------------------------------- /src/gateways/stdioToStatefulStreamableHttp.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import cors, { type CorsOptions } from 'cors' 3 | import { spawn } from 'child_process' 4 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 5 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' 6 | import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' 7 | import { Logger } from '../types.js' 8 | import { getVersion } from '../lib/getVersion.js' 9 | import { onSignals } from '../lib/onSignals.js' 10 | import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js' 11 | import { randomUUID } from 'node:crypto' 12 | import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' 13 | import { SessionAccessCounter } from '../lib/sessionAccessCounter.js' 14 | 15 | export interface StdioToStreamableHttpArgs { 16 | stdioCmd: string 17 | port: number 18 | streamableHttpPath: string 19 | logger: Logger 20 | corsOrigin: CorsOptions['origin'] 21 | healthEndpoints: string[] 22 | headers: Record 23 | sessionTimeout: number | null 24 | } 25 | 26 | const setResponseHeaders = ({ 27 | res, 28 | headers, 29 | }: { 30 | res: express.Response 31 | headers: Record 32 | }) => 33 | Object.entries(headers).forEach(([key, value]) => { 34 | res.setHeader(key, value) 35 | }) 36 | 37 | export async function stdioToStatefulStreamableHttp( 38 | args: StdioToStreamableHttpArgs, 39 | ) { 40 | const { 41 | stdioCmd, 42 | port, 43 | streamableHttpPath, 44 | logger, 45 | corsOrigin, 46 | healthEndpoints, 47 | headers, 48 | sessionTimeout, 49 | } = args 50 | 51 | logger.info( 52 | ` - Headers: ${Object(headers).length ? JSON.stringify(headers) : '(none)'}`, 53 | ) 54 | logger.info(` - port: ${port}`) 55 | logger.info(` - stdio: ${stdioCmd}`) 56 | logger.info(` - streamableHttpPath: ${streamableHttpPath}`) 57 | 58 | logger.info( 59 | ` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`, 60 | ) 61 | logger.info( 62 | ` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`, 63 | ) 64 | logger.info( 65 | ` - Session timeout: ${sessionTimeout ? `${sessionTimeout}ms` : 'disabled'}`, 66 | ) 67 | 68 | onSignals({ logger }) 69 | 70 | const app = express() 71 | app.use(express.json()) 72 | 73 | if (corsOrigin) { 74 | app.use( 75 | cors({ 76 | origin: corsOrigin, 77 | exposedHeaders: ['Mcp-Session-Id'], 78 | }), 79 | ) 80 | } 81 | 82 | for (const ep of healthEndpoints) { 83 | app.get(ep, (_req, res) => { 84 | setResponseHeaders({ 85 | res, 86 | headers, 87 | }) 88 | res.send('ok') 89 | }) 90 | } 91 | 92 | // Map to store transports by session ID 93 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {} 94 | 95 | // Session access counter for timeout management 96 | const sessionCounter = sessionTimeout 97 | ? new SessionAccessCounter( 98 | sessionTimeout, 99 | (sessionId: string) => { 100 | logger.info(`Session ${sessionId} timed out, cleaning up`) 101 | const transport = transports[sessionId] 102 | if (transport) { 103 | transport.close() 104 | } 105 | delete transports[sessionId] 106 | }, 107 | logger, 108 | ) 109 | : null 110 | 111 | // Handle POST requests for client-to-server communication 112 | app.post(streamableHttpPath, async (req, res) => { 113 | // Check for existing session ID 114 | const sessionId = req.headers['mcp-session-id'] as string | undefined 115 | let transport: StreamableHTTPServerTransport 116 | 117 | if (sessionId && transports[sessionId]) { 118 | // Reuse existing transport 119 | transport = transports[sessionId] 120 | // Increment session access count 121 | sessionCounter?.inc(sessionId, 'POST request for existing session') 122 | } else if (!sessionId && isInitializeRequest(req.body)) { 123 | // New initialization request 124 | 125 | const server = new Server( 126 | { name: 'supergateway', version: getVersion() }, 127 | { capabilities: {} }, 128 | ) 129 | 130 | transport = new StreamableHTTPServerTransport({ 131 | sessionIdGenerator: () => randomUUID(), 132 | onsessioninitialized: (sessionId) => { 133 | // Store the transport by session ID 134 | transports[sessionId] = transport 135 | // Initialize session access count 136 | sessionCounter?.inc(sessionId, 'session initialization') 137 | }, 138 | }) 139 | await server.connect(transport) 140 | const child = spawn(stdioCmd, { shell: true }) 141 | child.on('exit', (code, signal) => { 142 | logger.error(`Child exited: code=${code}, signal=${signal}`) 143 | transport.close() 144 | }) 145 | 146 | let buffer = '' 147 | child.stdout.on('data', (chunk: Buffer) => { 148 | buffer += chunk.toString('utf8') 149 | const lines = buffer.split(/\r?\n/) 150 | buffer = lines.pop() ?? '' 151 | lines.forEach((line) => { 152 | if (!line.trim()) return 153 | try { 154 | const jsonMsg = JSON.parse(line) 155 | logger.info('Child → StreamableHttp:', line) 156 | try { 157 | transport.send(jsonMsg) 158 | } catch (e) { 159 | logger.error(`Failed to send to StreamableHttp`, e) 160 | } 161 | } catch { 162 | logger.error(`Child non-JSON: ${line}`) 163 | } 164 | }) 165 | }) 166 | 167 | child.stderr.on('data', (chunk: Buffer) => { 168 | logger.error(`Child stderr: ${chunk.toString('utf8')}`) 169 | }) 170 | 171 | transport.onmessage = (msg: JSONRPCMessage) => { 172 | logger.info(`StreamableHttp → Child: ${JSON.stringify(msg)}`) 173 | child.stdin.write(JSON.stringify(msg) + '\n') 174 | } 175 | 176 | transport.onclose = () => { 177 | logger.info(`StreamableHttp connection closed (session ${sessionId})`) 178 | if (transport.sessionId) { 179 | sessionCounter?.clear( 180 | transport.sessionId, 181 | false, 182 | 'transport being closed', 183 | ) 184 | delete transports[transport.sessionId] 185 | } 186 | child.kill() 187 | } 188 | 189 | transport.onerror = (err) => { 190 | logger.error(`StreamableHttp error (session ${sessionId}):`, err) 191 | if (transport.sessionId) { 192 | sessionCounter?.clear( 193 | transport.sessionId, 194 | false, 195 | 'transport emitting error', 196 | ) 197 | delete transports[transport.sessionId] 198 | } 199 | child.kill() 200 | } 201 | } else { 202 | // Invalid request 203 | res.status(400).json({ 204 | jsonrpc: '2.0', 205 | error: { 206 | code: -32000, 207 | message: 'Bad Request: No valid session ID provided', 208 | }, 209 | id: null, 210 | }) 211 | return 212 | } 213 | 214 | // Decrement session access count when response ends 215 | let responseEnded = false 216 | const handleResponseEnd = (event: string) => { 217 | if (!responseEnded && transport.sessionId) { 218 | responseEnded = true 219 | logger.info(`Response ${event}`, transport.sessionId) 220 | sessionCounter?.dec(transport.sessionId, `POST response ${event}`) 221 | } 222 | } 223 | 224 | res.on('finish', () => handleResponseEnd('finished')) 225 | res.on('close', () => handleResponseEnd('closed')) 226 | 227 | // Handle the request 228 | await transport.handleRequest(req, res, req.body) 229 | }) 230 | 231 | // Reusable handler for GET and DELETE requests 232 | const handleSessionRequest = async ( 233 | req: express.Request, 234 | res: express.Response, 235 | ) => { 236 | const sessionId = req.headers['mcp-session-id'] as string | undefined 237 | if (!sessionId || !transports[sessionId]) { 238 | res.status(400).send('Invalid or missing session ID') 239 | return 240 | } 241 | 242 | // Increment session access count 243 | sessionCounter?.inc(sessionId, `${req.method} request for existing session`) 244 | 245 | // Decrement session access count when response ends 246 | let responseEnded = false 247 | const handleResponseEnd = (event: string) => { 248 | if (!responseEnded) { 249 | responseEnded = true 250 | logger.info(`Response ${event}`, sessionId) 251 | sessionCounter?.dec(sessionId, `${req.method} response ${event}`) 252 | } 253 | } 254 | 255 | res.on('finish', () => handleResponseEnd('finished')) 256 | res.on('close', () => handleResponseEnd('closed')) 257 | 258 | const transport = transports[sessionId] 259 | await transport.handleRequest(req, res) 260 | } 261 | 262 | // Handle GET requests for server-to-client notifications via SSE 263 | app.get(streamableHttpPath, handleSessionRequest) 264 | 265 | // Handle DELETE requests for session termination 266 | app.delete(streamableHttpPath, handleSessionRequest) 267 | 268 | app.listen(port, () => { 269 | logger.info(`Listening on port ${port}`) 270 | logger.info( 271 | `StreamableHttp endpoint: http://localhost:${port}${streamableHttpPath}`, 272 | ) 273 | }) 274 | } 275 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * index.ts 4 | * 5 | * Run MCP stdio servers over SSE, convert between stdio, SSE, WS. 6 | * 7 | * Usage: 8 | * # stdio→SSE 9 | * npx -y supergateway --stdio "npx -y @modelcontextprotocol/server-filesystem /" \ 10 | * --port 8000 --baseUrl http://localhost:8000 --ssePath /sse --messagePath /message 11 | * 12 | * # SSE→stdio 13 | * npx -y supergateway --sse "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app" 14 | * 15 | * # stdio→WS 16 | * npx -y supergateway --stdio "npx -y @modelcontextprotocol/server-filesystem /" --outputTransport ws 17 | * 18 | * # Streamable HTTP→stdio 19 | * npx -y supergateway --streamableHttp "https://mcp-server.example.com/mcp" 20 | */ 21 | 22 | import yargs from 'yargs' 23 | import { hideBin } from 'yargs/helpers' 24 | import { stdioToSse } from './gateways/stdioToSse.js' 25 | import { sseToStdio } from './gateways/sseToStdio.js' 26 | import { stdioToWs } from './gateways/stdioToWs.js' 27 | import { streamableHttpToStdio } from './gateways/streamableHttpToStdio.js' 28 | import { headers } from './lib/headers.js' 29 | import { corsOrigin } from './lib/corsOrigin.js' 30 | import { getLogger } from './lib/getLogger.js' 31 | import { stdioToStatelessStreamableHttp } from './gateways/stdioToStatelessStreamableHttp.js' 32 | import { stdioToStatefulStreamableHttp } from './gateways/stdioToStatefulStreamableHttp.js' 33 | 34 | async function main() { 35 | const argv = yargs(hideBin(process.argv)) 36 | .option('stdio', { 37 | type: 'string', 38 | description: 'Command to run an MCP server over Stdio', 39 | }) 40 | .option('sse', { 41 | type: 'string', 42 | description: 'SSE URL to connect to', 43 | }) 44 | .option('streamableHttp', { 45 | type: 'string', 46 | description: 'Streamable HTTP URL to connect to', 47 | }) 48 | .option('outputTransport', { 49 | type: 'string', 50 | choices: ['stdio', 'sse', 'ws', 'streamableHttp'], 51 | default: () => { 52 | const args = hideBin(process.argv) 53 | 54 | if (args.includes('--stdio')) return 'sse' 55 | if (args.includes('--sse')) return 'stdio' 56 | if (args.includes('--streamableHttp')) return 'stdio' 57 | 58 | return undefined 59 | }, 60 | description: 61 | 'Transport for output. Default is "sse" when using --stdio and "stdio" when using --sse or --streamableHttp.', 62 | }) 63 | .option('port', { 64 | type: 'number', 65 | default: 8000, 66 | description: '(stdio→SSE, stdio→WS) Port for output MCP server', 67 | }) 68 | .option('baseUrl', { 69 | type: 'string', 70 | default: '', 71 | description: '(stdio→SSE) Base URL for output MCP server', 72 | }) 73 | .option('ssePath', { 74 | type: 'string', 75 | default: '/sse', 76 | description: '(stdio→SSE) Path for SSE subscriptions', 77 | }) 78 | .option('messagePath', { 79 | type: 'string', 80 | default: '/message', 81 | description: '(stdio→SSE, stdio→WS) Path for messages', 82 | }) 83 | .option('streamableHttpPath', { 84 | type: 'string', 85 | default: '/mcp', 86 | description: '(stdio→StreamableHttp) Path for StreamableHttp', 87 | }) 88 | .option('logLevel', { 89 | choices: ['debug', 'info', 'none'] as const, 90 | default: 'info', 91 | description: 'Logging level', 92 | }) 93 | .option('cors', { 94 | type: 'array', 95 | description: 96 | 'Enable CORS. Use --cors with no values to allow all origins, or supply one or more allowed origins (e.g. --cors "http://example.com" or --cors "/example\\.com$/" for regex matching).', 97 | }) 98 | .option('healthEndpoint', { 99 | type: 'array', 100 | default: [], 101 | description: 102 | 'One or more endpoints returning "ok", e.g. --healthEndpoint /healthz --healthEndpoint /readyz', 103 | }) 104 | .option('header', { 105 | type: 'array', 106 | default: [], 107 | description: 108 | 'Headers to be added to the request headers, e.g. --header "x-user-id: 123"', 109 | }) 110 | .option('oauth2Bearer', { 111 | type: 'string', 112 | description: 113 | 'Authorization header to be added, e.g. --oauth2Bearer "some-access-token" adds "Authorization: Bearer some-access-token"', 114 | }) 115 | .option('stateful', { 116 | type: 'boolean', 117 | default: false, 118 | description: 119 | 'Whether the server is stateful. Only supported for stdio→StreamableHttp.', 120 | }) 121 | .option('sessionTimeout', { 122 | type: 'number', 123 | description: 124 | 'Session timeout in milliseconds. Only supported for stateful stdio→StreamableHttp. If not set, the session will only be deleted when client transport explicitly terminates the session.', 125 | }) 126 | .option('protocolVersion', { 127 | type: 'string', 128 | description: 129 | 'MCP protocol version to use for auto-initialization. Defaults to "2024-11-05" if not specified.', 130 | default: '2024-11-05', 131 | }) 132 | .help() 133 | .parseSync() 134 | 135 | const hasStdio = Boolean(argv.stdio) 136 | const hasSse = Boolean(argv.sse) 137 | const hasStreamableHttp = Boolean(argv.streamableHttp) 138 | 139 | const activeCount = [hasStdio, hasSse, hasStreamableHttp].filter( 140 | Boolean, 141 | ).length 142 | 143 | const logger = getLogger({ 144 | logLevel: argv.logLevel, 145 | outputTransport: argv.outputTransport as string, 146 | }) 147 | 148 | if (activeCount === 0) { 149 | logger.error( 150 | 'Error: You must specify one of --stdio, --sse, or --streamableHttp', 151 | ) 152 | process.exit(1) 153 | } else if (activeCount > 1) { 154 | logger.error( 155 | 'Error: Specify only one of --stdio, --sse, or --streamableHttp, not multiple', 156 | ) 157 | process.exit(1) 158 | } 159 | 160 | logger.info('Starting...') 161 | logger.info( 162 | 'Supergateway is supported by Supermachine (hosted MCPs) - https://supermachine.ai', 163 | ) 164 | logger.info(` - outputTransport: ${argv.outputTransport}`) 165 | 166 | try { 167 | if (hasStdio) { 168 | if (argv.outputTransport === 'sse') { 169 | await stdioToSse({ 170 | stdioCmd: argv.stdio!, 171 | port: argv.port, 172 | baseUrl: argv.baseUrl, 173 | ssePath: argv.ssePath, 174 | messagePath: argv.messagePath, 175 | logger, 176 | corsOrigin: corsOrigin({ argv }), 177 | healthEndpoints: argv.healthEndpoint as string[], 178 | headers: headers({ 179 | argv, 180 | logger, 181 | }), 182 | }) 183 | } else if (argv.outputTransport === 'ws') { 184 | await stdioToWs({ 185 | stdioCmd: argv.stdio!, 186 | port: argv.port, 187 | messagePath: argv.messagePath, 188 | logger, 189 | corsOrigin: corsOrigin({ argv }), 190 | healthEndpoints: argv.healthEndpoint as string[], 191 | }) 192 | } else if (argv.outputTransport === 'streamableHttp') { 193 | const stateful = argv.stateful 194 | if (stateful) { 195 | logger.info('Running stateful server') 196 | 197 | let sessionTimeout: null | number 198 | if (typeof argv.sessionTimeout === 'number') { 199 | if (argv.sessionTimeout <= 0) { 200 | logger.error( 201 | `Error: \`sessionTimeout\` must be a positive number, received: ${argv.sessionTimeout}`, 202 | ) 203 | process.exit(1) 204 | } 205 | 206 | sessionTimeout = argv.sessionTimeout 207 | } else { 208 | sessionTimeout = null 209 | } 210 | 211 | await stdioToStatefulStreamableHttp({ 212 | stdioCmd: argv.stdio!, 213 | port: argv.port, 214 | streamableHttpPath: argv.streamableHttpPath, 215 | logger, 216 | corsOrigin: corsOrigin({ argv }), 217 | healthEndpoints: argv.healthEndpoint as string[], 218 | headers: headers({ 219 | argv, 220 | logger, 221 | }), 222 | sessionTimeout, 223 | }) 224 | } else { 225 | logger.info('Running stateless server') 226 | 227 | await stdioToStatelessStreamableHttp({ 228 | stdioCmd: argv.stdio!, 229 | port: argv.port, 230 | streamableHttpPath: argv.streamableHttpPath, 231 | logger, 232 | corsOrigin: corsOrigin({ argv }), 233 | healthEndpoints: argv.healthEndpoint as string[], 234 | headers: headers({ 235 | argv, 236 | logger, 237 | }), 238 | protocolVersion: argv.protocolVersion, 239 | }) 240 | } 241 | } else { 242 | logger.error(`Error: stdio→${argv.outputTransport} not supported`) 243 | process.exit(1) 244 | } 245 | } else if (hasSse) { 246 | if (argv.outputTransport === 'stdio') { 247 | await sseToStdio({ 248 | sseUrl: argv.sse!, 249 | logger, 250 | headers: headers({ 251 | argv, 252 | logger, 253 | }), 254 | }) 255 | } else { 256 | logger.error(`Error: sse→${argv.outputTransport} not supported`) 257 | process.exit(1) 258 | } 259 | } else if (hasStreamableHttp) { 260 | if (argv.outputTransport === 'stdio') { 261 | await streamableHttpToStdio({ 262 | streamableHttpUrl: argv.streamableHttp!, 263 | logger, 264 | headers: headers({ 265 | argv, 266 | logger, 267 | }), 268 | }) 269 | } else { 270 | logger.error( 271 | `Error: streamableHttp→${argv.outputTransport} not supported`, 272 | ) 273 | process.exit(1) 274 | } 275 | } else { 276 | logger.error('Error: Invalid input transport') 277 | process.exit(1) 278 | } 279 | } catch (err) { 280 | logger.error('Fatal error:', err) 281 | process.exit(1) 282 | } 283 | } 284 | 285 | main() 286 | -------------------------------------------------------------------------------- /src/gateways/stdioToStatelessStreamableHttp.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import cors, { type CorsOptions } from 'cors' 3 | import { spawn } from 'child_process' 4 | import { Server } from '@modelcontextprotocol/sdk/server/index.js' 5 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' 6 | import { 7 | JSONRPCMessage, 8 | isInitializeRequest, 9 | } from '@modelcontextprotocol/sdk/types.js' 10 | import { Logger } from '../types.js' 11 | import { getVersion } from '../lib/getVersion.js' 12 | import { onSignals } from '../lib/onSignals.js' 13 | import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js' 14 | 15 | export interface StdioToStreamableHttpArgs { 16 | stdioCmd: string 17 | port: number 18 | streamableHttpPath: string 19 | logger: Logger 20 | corsOrigin: CorsOptions['origin'] 21 | healthEndpoints: string[] 22 | headers: Record 23 | protocolVersion: string 24 | } 25 | 26 | const setResponseHeaders = ({ 27 | res, 28 | headers, 29 | }: { 30 | res: express.Response 31 | headers: Record 32 | }) => 33 | Object.entries(headers).forEach(([key, value]) => { 34 | res.setHeader(key, value) 35 | }) 36 | 37 | // Helper function to create initialize request 38 | const createInitializeRequest = ( 39 | id: string | number, 40 | protocolVersion: string, 41 | ): JSONRPCMessage => ({ 42 | jsonrpc: '2.0', 43 | id, 44 | method: 'initialize', 45 | params: { 46 | protocolVersion, 47 | capabilities: { 48 | roots: { 49 | listChanged: true, 50 | }, 51 | sampling: {}, 52 | }, 53 | clientInfo: { 54 | name: 'supergateway', 55 | version: getVersion(), 56 | }, 57 | }, 58 | }) 59 | 60 | // Helper function to create initialized notification 61 | const createInitializedNotification = (): JSONRPCMessage => ({ 62 | jsonrpc: '2.0', 63 | method: 'notifications/initialized', 64 | }) 65 | 66 | export async function stdioToStatelessStreamableHttp( 67 | args: StdioToStreamableHttpArgs, 68 | ) { 69 | const { 70 | stdioCmd, 71 | port, 72 | streamableHttpPath, 73 | logger, 74 | corsOrigin, 75 | healthEndpoints, 76 | headers, 77 | protocolVersion, 78 | } = args 79 | 80 | logger.info( 81 | ` - Headers: ${Object(headers).length ? JSON.stringify(headers) : '(none)'}`, 82 | ) 83 | logger.info(` - port: ${port}`) 84 | logger.info(` - stdio: ${stdioCmd}`) 85 | logger.info(` - streamableHttpPath: ${streamableHttpPath}`) 86 | logger.info(` - protocolVersion: ${protocolVersion}`) 87 | 88 | logger.info( 89 | ` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`, 90 | ) 91 | logger.info( 92 | ` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`, 93 | ) 94 | 95 | onSignals({ logger }) 96 | 97 | const app = express() 98 | app.use(express.json()) 99 | 100 | if (corsOrigin) { 101 | app.use(cors({ origin: corsOrigin })) 102 | } 103 | 104 | for (const ep of healthEndpoints) { 105 | app.get(ep, (_req, res) => { 106 | setResponseHeaders({ 107 | res, 108 | headers, 109 | }) 110 | res.send('ok') 111 | }) 112 | } 113 | 114 | app.post(streamableHttpPath, async (req, res) => { 115 | // In stateless mode, create a new instance of transport and server for each request 116 | // to ensure complete isolation. A single instance would cause request ID collisions 117 | // when multiple clients connect concurrently. 118 | 119 | try { 120 | const server = new Server( 121 | { name: 'supergateway', version: getVersion() }, 122 | { capabilities: {} }, 123 | ) 124 | const transport = new StreamableHTTPServerTransport({ 125 | sessionIdGenerator: undefined, 126 | }) 127 | 128 | await server.connect(transport) 129 | const child = spawn(stdioCmd, { shell: true }) 130 | child.on('exit', (code, signal) => { 131 | logger.error(`Child exited: code=${code}, signal=${signal}`) 132 | transport.close() 133 | }) 134 | 135 | // State tracking for initialization flow 136 | let isInitialized = false 137 | let initializeRequestId: string | number | null = null // Current initialize request ID 138 | let isAutoInitializing = false // Flag to indicate if we're auto-initializing 139 | let pendingOriginalMessage: JSONRPCMessage | null = null 140 | 141 | let buffer = '' 142 | child.stdout.on('data', (chunk: Buffer) => { 143 | buffer += chunk.toString('utf8') 144 | const lines = buffer.split(/\r?\n/) 145 | buffer = lines.pop() ?? '' 146 | lines.forEach((line) => { 147 | if (!line.trim()) return 148 | try { 149 | const jsonMsg = JSON.parse(line) 150 | logger.info('Child → StreamableHttp:', line) 151 | 152 | // Handle initialize response (both auto and client initiated) 153 | if (initializeRequestId && jsonMsg.id === initializeRequestId) { 154 | logger.info('Initialize response received') 155 | isInitialized = true 156 | 157 | // If this was our auto-initialization, send initialized notification and pending message 158 | if (isAutoInitializing) { 159 | // Send initialized notification 160 | const initializedNotification = createInitializedNotification() 161 | logger.info( 162 | `StreamableHttp → Child (initialized): ${JSON.stringify(initializedNotification)}`, 163 | ) 164 | child.stdin.write( 165 | JSON.stringify(initializedNotification) + '\n', 166 | ) 167 | 168 | // Now send the original message 169 | if (pendingOriginalMessage) { 170 | logger.info( 171 | `StreamableHttp → Child (original): ${JSON.stringify(pendingOriginalMessage)}`, 172 | ) 173 | child.stdin.write( 174 | JSON.stringify(pendingOriginalMessage) + '\n', 175 | ) 176 | pendingOriginalMessage = null 177 | } 178 | 179 | // Reset auto-initialize tracking 180 | isAutoInitializing = false 181 | initializeRequestId = null 182 | 183 | // Don't forward our auto-initialize response to the client 184 | return 185 | } else { 186 | // Client-initiated initialize response, just reset tracking 187 | initializeRequestId = null 188 | } 189 | } 190 | 191 | try { 192 | transport.send(jsonMsg) 193 | } catch (e) { 194 | logger.error(`Failed to send to StreamableHttp`, e) 195 | } 196 | } catch { 197 | logger.error(`Child non-JSON: ${line}`) 198 | } 199 | }) 200 | }) 201 | 202 | child.stderr.on('data', (chunk: Buffer) => { 203 | logger.error(`Child stderr: ${chunk.toString('utf8')}`) 204 | }) 205 | 206 | transport.onmessage = (msg: JSONRPCMessage) => { 207 | logger.info(`StreamableHttp → Child: ${JSON.stringify(msg)}`) 208 | 209 | // Check if we need to auto-initialize first 210 | if (!isInitialized && !isInitializeRequest(msg)) { 211 | // Store the original message and send initialize first 212 | pendingOriginalMessage = msg 213 | initializeRequestId = `init_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 214 | isAutoInitializing = true 215 | 216 | logger.info( 217 | 'Non-initialize message detected, sending auto-initialize request first', 218 | ) 219 | const initRequest = createInitializeRequest( 220 | initializeRequestId, 221 | protocolVersion, 222 | ) 223 | logger.info( 224 | `StreamableHttp → Child (auto-initialize): ${JSON.stringify(initRequest)}`, 225 | ) 226 | child.stdin.write(JSON.stringify(initRequest) + '\n') 227 | 228 | // Don't send the original message yet - it will be sent after initialization 229 | return 230 | } 231 | 232 | // Track initialize request ID (both client and auto) 233 | if (isInitializeRequest(msg) && 'id' in msg && msg.id !== undefined) { 234 | initializeRequestId = msg.id 235 | isAutoInitializing = false // This is client-initiated 236 | logger.info(`Tracking initialize request ID: ${msg.id}`) 237 | } 238 | 239 | // Send all messages to child process normally 240 | child.stdin.write(JSON.stringify(msg) + '\n') 241 | } 242 | 243 | transport.onclose = () => { 244 | logger.info('StreamableHttp connection closed') 245 | child.kill() 246 | } 247 | 248 | transport.onerror = (err) => { 249 | logger.error(`StreamableHttp error:`, err) 250 | child.kill() 251 | } 252 | 253 | await transport.handleRequest(req, res, req.body) 254 | } catch (error) { 255 | logger.error('Error handling MCP request:', error) 256 | if (!res.headersSent) { 257 | res.status(500).json({ 258 | jsonrpc: '2.0', 259 | error: { 260 | code: -32603, 261 | message: 'Internal server error', 262 | }, 263 | id: null, 264 | }) 265 | } 266 | } 267 | }) 268 | 269 | app.get(streamableHttpPath, async (req, res) => { 270 | logger.info('Received GET MCP request') 271 | res.writeHead(405).end( 272 | JSON.stringify({ 273 | jsonrpc: '2.0', 274 | error: { 275 | code: -32000, 276 | message: 'Method not allowed.', 277 | }, 278 | id: null, 279 | }), 280 | ) 281 | }) 282 | 283 | app.delete(streamableHttpPath, async (req, res) => { 284 | logger.info('Received DELETE MCP request') 285 | res.writeHead(405).end( 286 | JSON.stringify({ 287 | jsonrpc: '2.0', 288 | error: { 289 | code: -32000, 290 | message: 'Method not allowed.', 291 | }, 292 | id: null, 293 | }), 294 | ) 295 | }) 296 | 297 | app.listen(port, () => { 298 | logger.info(`Listening on port ${port}`) 299 | logger.info( 300 | `StreamableHttp endpoint: http://localhost:${port}${streamableHttpPath}`, 301 | ) 302 | }) 303 | } 304 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Supergateway: Run stdio MCP servers over SSE and WS](https://raw.githubusercontent.com/supercorp-ai/supergateway/main/supergateway.png) 2 | 3 | **Supergateway** runs **MCP stdio-based servers** over **SSE (Server-Sent Events)** or **WebSockets (WS)** with one command. This is useful for remote access, debugging, or connecting to clients when your MCP server only supports stdio. 4 | 5 | Supported by [Supermachine](https://supermachine.ai) (hosted MCPs), [Superinterface](https://superinterface.ai), and [Supercorp](https://supercorp.ai). 6 | 7 | ## Installation & Usage 8 | 9 | Run Supergateway via `npx`: 10 | 11 | ```bash 12 | npx -y supergateway --stdio "uvx mcp-server-git" 13 | ``` 14 | 15 | - **`--stdio "command"`**: Command that runs an MCP server over stdio 16 | - **`--sse "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app"`**: SSE URL to connect to (SSE→stdio mode) 17 | - **`--streamableHttp "https://mcp-server.example.com/mcp"`**: Streamable HTTP URL to connect to (StreamableHttp→stdio mode) 18 | - **`--outputTransport stdio | sse | ws | streamableHttp`**: Output MCP transport (default: `sse` with `--stdio`, `stdio` with `--sse` or `--streamableHttp`) 19 | - **`--port 8000`**: Port to listen on (stdio→SSE or stdio→WS mode, default: `8000`) 20 | - **`--baseUrl "http://localhost:8000"`**: Base URL for SSE or WS clients (stdio→SSE mode; optional) 21 | - **`--ssePath "/sse"`**: Path for SSE subscriptions (stdio→SSE mode, default: `/sse`) 22 | - **`--messagePath "/message"`**: Path for messages (stdio→SSE or stdio→WS mode, default: `/message`) 23 | - **`--streamableHttpPath "/mcp"`**: Path for Streamable HTTP (stdio→Streamable HTTP mode, default: `/mcp`) 24 | - **`--stateful`**: Run stdio→Streamable HTTP in stateful mode 25 | - **`--sessionTimeout 60000`**: Session timeout in milliseconds (stateful stdio→Streamable HTTP mode only) 26 | - **`--header "x-user-id: 123"`**: Add one or more headers (stdio→SSE, SSE→stdio, or Streamable HTTP→stdio mode; can be used multiple times) 27 | - **`--oauth2Bearer "some-access-token"`**: Adds an `Authorization` header with the provided Bearer token 28 | - **`--logLevel debug | info | none`**: Controls logging level (default: `info`). Use `debug` for more verbose logs, `none` to suppress all logs. 29 | - **`--cors`**: Enable CORS (stdio→SSE or stdio→WS mode). Use `--cors` with no values to allow all origins, or supply one or more allowed origins (e.g. `--cors "http://example.com"` or `--cors "/example\\.com$/"` for regex matching). 30 | - **`--healthEndpoint /healthz`**: Register one or more endpoints (stdio→SSE or stdio→WS mode; can be used multiple times) that respond with `"ok"` 31 | 32 | ## stdio → SSE 33 | 34 | Expose an MCP stdio server as an SSE server: 35 | 36 | ```bash 37 | npx -y supergateway \ 38 | --stdio "npx -y @modelcontextprotocol/server-filesystem ./my-folder" \ 39 | --port 8000 --baseUrl http://localhost:8000 \ 40 | --ssePath /sse --messagePath /message 41 | ``` 42 | 43 | - **Subscribe to events**: `GET http://localhost:8000/sse` 44 | - **Send messages**: `POST http://localhost:8000/message` 45 | 46 | ## SSE → stdio 47 | 48 | Connect to a remote SSE server and expose locally via stdio: 49 | 50 | ```bash 51 | npx -y supergateway --sse "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app" 52 | ``` 53 | 54 | Useful for integrating remote SSE MCP servers into local command-line environments. 55 | 56 | You can also pass headers when sending requests. This is useful for authentication: 57 | 58 | ```bash 59 | npx -y supergateway \ 60 | --sse "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app" \ 61 | --oauth2Bearer "some-access-token" \ 62 | --header "X-My-Header: another-header-value" 63 | ``` 64 | 65 | ## Streamable HTTP → stdio 66 | 67 | Connect to a remote Streamable HTTP server and expose locally via stdio: 68 | 69 | ```bash 70 | npx -y supergateway --streamableHttp "https://mcp-server.example.com/mcp" 71 | ``` 72 | 73 | This mode is useful for connecting to MCP servers that use the newer Streamable HTTP transport protocol. Like SSE mode, you can also pass headers for authentication: 74 | 75 | ```bash 76 | npx -y supergateway \ 77 | --streamableHttp "https://mcp-server.example.com/mcp" \ 78 | --oauth2Bearer "some-access-token" \ 79 | --header "X-My-Header: another-header-value" 80 | ``` 81 | 82 | ## stdio → Streamable HTTP 83 | 84 | Expose an MCP stdio server as a Streamable HTTP server. 85 | 86 | ### Stateless mode 87 | 88 | ```bash 89 | npx -y supergateway \ 90 | --stdio "npx -y @modelcontextprotocol/server-filesystem ./my-folder" \ 91 | --outputTransport streamableHttp \ 92 | --port 8000 93 | ``` 94 | 95 | ### Stateful mode 96 | 97 | ```bash 98 | npx -y supergateway \ 99 | --stdio "npx -y @modelcontextprotocol/server-filesystem ./my-folder" \ 100 | --outputTransport streamableHttp --stateful \ 101 | --sessionTimeout 60000 --port 8000 102 | ``` 103 | 104 | The Streamable HTTP endpoint defaults to `http://localhost:8000/mcp` (configurable via `--streamableHttpPath`). 105 | 106 | ## stdio → WS 107 | 108 | Expose an MCP stdio server as a WebSocket server: 109 | 110 | ```bash 111 | npx -y supergateway \ 112 | --stdio "npx -y @modelcontextprotocol/server-filesystem ./my-folder" \ 113 | --port 8000 --outputTransport ws --messagePath /message 114 | ``` 115 | 116 | - **WebSocket endpoint**: `ws://localhost:8000/message` 117 | 118 | ## Example with MCP Inspector (stdio → SSE mode) 119 | 120 | 1. **Run Supergateway**: 121 | 122 | ```bash 123 | npx -y supergateway --port 8000 \ 124 | --stdio "npx -y @modelcontextprotocol/server-filesystem /Users/MyName/Desktop" 125 | ``` 126 | 127 | 2. **Use MCP Inspector**: 128 | 129 | ```bash 130 | npx @modelcontextprotocol/inspector 131 | ``` 132 | 133 | You can now list tools, resources, or perform MCP actions via Supergateway. 134 | 135 | ## Using with ngrok 136 | 137 | Use [ngrok](https://ngrok.com/) to share your local MCP server publicly: 138 | 139 | ```bash 140 | npx -y supergateway --port 8000 --stdio "npx -y @modelcontextprotocol/server-filesystem ." 141 | 142 | # In another terminal: 143 | ngrok http 8000 144 | ``` 145 | 146 | ngrok provides a public URL for remote access. 147 | 148 | MCP server will be available at URL similar to: https://1234-567-890-12-456.ngrok-free.app/sse 149 | 150 | ## Running with Docker 151 | 152 | A Docker-based workflow avoids local Node.js setup. A ready-to-run Docker image is available here: 153 | [supercorp/supergateway](https://hub.docker.com/r/supercorp/supergateway). Also on GHCR: [ghcr.io/supercorp-ai/supergateway](https://github.com/supercorp-ai/supergateway/pkgs/container/supergateway) 154 | 155 | ### Using the Official Image 156 | 157 | ```bash 158 | docker run -it --rm -p 8000:8000 supercorp/supergateway \ 159 | --stdio "npx -y @modelcontextprotocol/server-filesystem /" \ 160 | --port 8000 161 | ``` 162 | 163 | Docker pulls the image automatically. The MCP server runs in the container’s root directory (`/`). You can mount host directories if needed. 164 | 165 | #### Images with dependencies 166 | 167 | Pull any of these pre-built Supergateway images for various dependencies you might need. 168 | 169 | - **uvx** 170 | Supergateway + uv/uvx, so you can call `uvx` directly: 171 | 172 | ```bash 173 | docker run -it --rm -p 8000:8000 supercorp/supergateway:uvx \ 174 | --stdio "uvx mcp-server-fetch" 175 | ``` 176 | 177 | - **deno** 178 | Supergateway + Deno, ready to run Deno-based MCP servers: 179 | ```bash 180 | docker run -it --rm -p 8000:8000 supercorp/supergateway:deno \ 181 | --stdio "deno run -A jsr:@omedia/mcp-server-drupal --drupal-url https://your-drupal-server.com" 182 | ``` 183 | 184 | ### Building the Image Yourself 185 | 186 | Use provided Dockerfile: 187 | 188 | ```bash 189 | docker build -f docker/base.Dockerfile -t supergateway . 190 | 191 | docker run -it --rm -p 8000:8000 supergateway --stdio "npx -y @modelcontextprotocol/server-filesystem ." 192 | ``` 193 | 194 | ## Using with Claude Desktop (SSE → stdio mode) 195 | 196 | Claude Desktop can use Supergateway’s SSE→stdio mode. 197 | 198 | ### NPX-based MCP Server Example 199 | 200 | ```json 201 | { 202 | "mcpServers": { 203 | "supermachineExampleNpx": { 204 | "command": "npx", 205 | "args": [ 206 | "-y", 207 | "supergateway", 208 | "--sse", 209 | "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app" 210 | ] 211 | } 212 | } 213 | } 214 | ``` 215 | 216 | ### Docker-based MCP Server Example 217 | 218 | ```json 219 | { 220 | "mcpServers": { 221 | "supermachineExampleDocker": { 222 | "command": "docker", 223 | "args": [ 224 | "run", 225 | "-i", 226 | "--rm", 227 | "supercorp/supergateway", 228 | "--sse", 229 | "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app" 230 | ] 231 | } 232 | } 233 | } 234 | ``` 235 | 236 | ## Using with Cursor (SSE → stdio mode) 237 | 238 | Cursor can also integrate with Supergateway in SSE→stdio mode. The configuration is similar to Claude Desktop. 239 | 240 | ### NPX-based MCP Server Example for Cursor 241 | 242 | ```json 243 | { 244 | "mcpServers": { 245 | "cursorExampleNpx": { 246 | "command": "npx", 247 | "args": [ 248 | "-y", 249 | "supergateway", 250 | "--sse", 251 | "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app" 252 | ] 253 | } 254 | } 255 | } 256 | ``` 257 | 258 | ### Docker-based MCP Server Example for Cursor 259 | 260 | ```json 261 | { 262 | "mcpServers": { 263 | "cursorExampleDocker": { 264 | "command": "docker", 265 | "args": [ 266 | "run", 267 | "-i", 268 | "--rm", 269 | "supercorp/supergateway", 270 | "--sse", 271 | "https://mcp-server-ab71a6b2-cd55-49d0-adba-562bc85956e3.supermachine.app" 272 | ] 273 | } 274 | } 275 | } 276 | ``` 277 | 278 | **Note:** Although the setup supports sending headers via the `--header` flag, if you need to pass an Authorization header (which typically includes a space, e.g. `"Bearer 123"`), you must use the `--oauth2Bearer` flag due to a known Cursor bug with spaces in command-line arguments. 279 | 280 | ## Why MCP? 281 | 282 | [Model Context Protocol](https://spec.modelcontextprotocol.io/) standardizes AI tool interactions. Supergateway converts MCP stdio servers into SSE or WS services, simplifying integration and debugging with web-based or remote clients. 283 | 284 | ## Advanced Configuration 285 | 286 | Supergateway emphasizes modularity: 287 | 288 | - Automatically manages JSON-RPC versioning. 289 | - Retransmits package metadata where possible. 290 | - stdio→SSE or stdio→WS mode logs via standard output; SSE→stdio mode logs via stderr. 291 | 292 | ## Additional resources 293 | 294 | - [Superargs](https://github.com/supercorp-ai/superargs) - provide arguments to MCP servers during runtime. 295 | 296 | ## Contributors 297 | 298 | - [@longfin](https://github.com/longfin) 299 | - [@griffinqiu](https://github.com/griffinqiu) 300 | - [@folkvir](https://github.com/folkvir) 301 | - [@wizizm](https://github.com/wizizm) 302 | - [@dtinth](https://github.com/dtinth) 303 | - [@rajivml](https://github.com/rajivml) 304 | - [@NicoBonaminio](https://github.com/NicoBonaminio) 305 | - [@sibbl](https://github.com/sibbl) 306 | - [@podarok](https://github.com/podarok) 307 | - [@jmn8718](https://github.com/jmn8718) 308 | - [@TraceIvan](https://github.com/TraceIvan) 309 | - [@zhoufei0622](https://github.com/zhoufei0622) 310 | - [@ezyang](https://github.com/ezyang) 311 | - [@aleksadvaisly](https://github.com/aleksadvaisly) 312 | - [@wuzhuoquan](https://github.com/wuzhuoquan) 313 | - [@mantrakp04](https://github.com/mantrakp04) 314 | - [@mheubi](https://github.com/mheubi) 315 | - [@mjmendo](https://github.com/mjmendo) 316 | - [@CyanMystery](https://github.com/CyanMystery) 317 | - [@earonesty](https://github.com/earonesty) 318 | - [@StefanBurscher](https://github.com/StefanBurscher) 319 | - [@tarasyarema](https://github.com/tarasyarema) 320 | - [@pcnfernando](https://github.com/pcnfernando) 321 | - [@Areo-Joe](https://github.com/Areo-Joe) 322 | - [@Joffref](https://github.com/Joffref) 323 | - [@michaeljguarino](https://github.com/michaeljguarino) 324 | 325 | ## Contributing 326 | 327 | Issues and PRs welcome. Please open one if you encounter problems or have feature suggestions. 328 | 329 | ## Tests 330 | 331 | Supergateway is tested with the Node Test Runner. 332 | 333 | To run the suite locally you need Node **24+**. Using [nvm](https://github.com/nvm-sh/nvm) you can install and activate it with: 334 | 335 | ```bash 336 | nvm install 24 337 | nvm use 24 338 | npm install 339 | npm run build 340 | npm test 341 | ``` 342 | 343 | The `tests/helpers/mock-mcp-server.js` script provides a local MCP server so all 344 | tests run without network access. 345 | 346 | ## License 347 | 348 | [MIT License](./LICENSE) 349 | --------------------------------------------------------------------------------