cprime-supergateway 3.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.github/workflows/docker-publish.yaml +79 -0
  2. package/.husky/pre-commit +17 -0
  3. package/.prettierignore +8 -0
  4. package/.prettierrc +5 -0
  5. package/AGENTS.md +29 -0
  6. package/LICENSE +21 -0
  7. package/README.md +348 -0
  8. package/dist/gateways/sseToStdio.js +139 -0
  9. package/dist/gateways/stdioToSse.js +147 -0
  10. package/dist/gateways/stdioToStatefulStreamableHttp.js +188 -0
  11. package/dist/gateways/stdioToStatelessStreamableHttp.js +208 -0
  12. package/dist/gateways/stdioToWs.js +113 -0
  13. package/dist/gateways/streamableHttpToStdio.js +134 -0
  14. package/dist/index.js +266 -0
  15. package/dist/lib/corsOrigin.js +23 -0
  16. package/dist/lib/getLogger.js +44 -0
  17. package/dist/lib/getVersion.js +16 -0
  18. package/dist/lib/headers.js +31 -0
  19. package/dist/lib/onSignals.js +27 -0
  20. package/dist/lib/serializeCorsOrigin.js +6 -0
  21. package/dist/lib/sessionAccessCounter.js +77 -0
  22. package/dist/server/websocket.js +102 -0
  23. package/dist/services/EncryptionService.js +236 -0
  24. package/dist/types.js +1 -0
  25. package/docker/base.Dockerfile +9 -0
  26. package/docker/deno.Dockerfile +2 -0
  27. package/docker/uvx.Dockerfile +3 -0
  28. package/docker-bake.hcl +51 -0
  29. package/package.json +61 -0
  30. package/scripts/decrypt-sample.ts +34 -0
  31. package/scripts/encryption-play.ts +145 -0
  32. package/src/gateways/sseToStdio.ts +195 -0
  33. package/src/gateways/stdioToSse.ts +260 -0
  34. package/src/gateways/stdioToStatefulStreamableHttp.ts +274 -0
  35. package/src/gateways/stdioToStatelessStreamableHttp.ts +303 -0
  36. package/src/gateways/stdioToWs.ts +151 -0
  37. package/src/gateways/streamableHttpToStdio.ts +196 -0
  38. package/src/index.ts +286 -0
  39. package/src/lib/corsOrigin.ts +31 -0
  40. package/src/lib/getLogger.ts +83 -0
  41. package/src/lib/getVersion.ts +17 -0
  42. package/src/lib/headers.ts +55 -0
  43. package/src/lib/initMongoClient.ts +10 -0
  44. package/src/lib/mcpServerLogRepository.ts +48 -0
  45. package/src/lib/onSignals.ts +39 -0
  46. package/src/lib/serializeCorsOrigin.ts +14 -0
  47. package/src/lib/sessionAccessCounter.ts +118 -0
  48. package/src/server/websocket.ts +121 -0
  49. package/src/services/encryptionService.ts +309 -0
  50. package/src/types.ts +4 -0
  51. package/supergateway.png +0 -0
  52. package/tests/baseUrl.test.ts +62 -0
  53. package/tests/concurrency.test.ts +137 -0
  54. package/tests/helpers/mock-mcp-server.js +94 -0
  55. package/tests/protocolVersion.test.ts +60 -0
  56. package/tests/stdioToStatefulStreamableHttp.test.ts +70 -0
  57. package/tests/stdioToStatelessStreamableHttp.test.ts +71 -0
  58. package/tests/streamableHttpCli.test.ts +24 -0
  59. package/tests/streamableHttpToStdio.test.ts +64 -0
  60. package/tsconfig.build.json +8 -0
  61. package/tsconfig.json +12 -0
  62. package/tsconfig.test.json +10 -0
@@ -0,0 +1,147 @@
1
+ import express from 'express';
2
+ import bodyParser from 'body-parser';
3
+ import cors from 'cors';
4
+ import { spawn } from 'child_process';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
7
+ import { getVersion } from '../lib/getVersion.js';
8
+ import { onSignals } from '../lib/onSignals.js';
9
+ import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js';
10
+ import { EncryptionService } from '../services/encryptionService.js';
11
+ const encryptionService = new EncryptionService('env');
12
+ const plaintext = await encryptionService.decryptText(process.env.ENCRYPTED_ENV ?? '', process.env.AAD_JSON ? JSON.parse(process.env.AAD_JSON) : {});
13
+ let decryptedEnvs = {};
14
+ try {
15
+ const asObj = JSON.parse(plaintext);
16
+ decryptedEnvs = asObj;
17
+ }
18
+ catch {
19
+ console.error('Failed to parse decrypted envs', plaintext);
20
+ }
21
+ const setResponseHeaders = ({ res, headers, }) => Object.entries(headers).forEach(([key, value]) => {
22
+ res.setHeader(key, value);
23
+ });
24
+ export async function stdioToSse(args) {
25
+ const { stdioCmd, port, baseUrl, ssePath, messagePath, logger, corsOrigin, healthEndpoints, headers, } = args;
26
+ logger.info(` - Headers: ${Object(headers).length ? JSON.stringify(headers) : '(none)'}`);
27
+ logger.info(` - port: ${port}`);
28
+ logger.info(` - stdio: ${stdioCmd}`);
29
+ if (baseUrl) {
30
+ logger.info(` - baseUrl: ${baseUrl}`);
31
+ }
32
+ logger.info(` - ssePath: ${ssePath}`);
33
+ logger.info(` - messagePath: ${messagePath}`);
34
+ logger.info(` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`);
35
+ logger.info(` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`);
36
+ onSignals({ logger });
37
+ const child = spawn(stdioCmd, {
38
+ shell: true,
39
+ env: { ...process.env, ...decryptedEnvs },
40
+ });
41
+ child.on('exit', (code, signal) => {
42
+ logger.error(`Child exited: code=${code}, signal=${signal}`);
43
+ process.exit(code ?? 1);
44
+ });
45
+ const server = new Server({ name: 'supergateway', version: getVersion() }, { capabilities: {} });
46
+ const sessions = {};
47
+ const app = express();
48
+ if (corsOrigin) {
49
+ app.use(cors({ origin: corsOrigin }));
50
+ }
51
+ app.use((req, res, next) => {
52
+ if (req.path === messagePath)
53
+ return next();
54
+ return bodyParser.json()(req, res, next);
55
+ });
56
+ for (const ep of healthEndpoints) {
57
+ app.get(ep, (_req, res) => {
58
+ setResponseHeaders({
59
+ res,
60
+ headers,
61
+ });
62
+ res.send('ok');
63
+ });
64
+ }
65
+ app.get(ssePath, async (req, res) => {
66
+ logger.info(`New SSE connection from ${req.ip}`);
67
+ setResponseHeaders({
68
+ res,
69
+ headers,
70
+ });
71
+ const sseTransport = new SSEServerTransport(`${baseUrl}${messagePath}`, res);
72
+ await server.connect(sseTransport);
73
+ const sessionId = sseTransport.sessionId;
74
+ if (sessionId) {
75
+ sessions[sessionId] = { transport: sseTransport, response: res };
76
+ }
77
+ sseTransport.onmessage = (msg) => {
78
+ logger.info(`SSE → Child (session ${sessionId}): ${JSON.stringify(msg)}`);
79
+ child.stdin.write(JSON.stringify(msg) + '\n');
80
+ };
81
+ sseTransport.onclose = () => {
82
+ logger.info(`SSE connection closed (session ${sessionId})`);
83
+ delete sessions[sessionId];
84
+ };
85
+ sseTransport.onerror = (err) => {
86
+ logger.error(`SSE error (session ${sessionId}):`, err);
87
+ delete sessions[sessionId];
88
+ };
89
+ req.on('close', () => {
90
+ logger.info(`Client disconnected (session ${sessionId})`);
91
+ delete sessions[sessionId];
92
+ });
93
+ });
94
+ // @ts-ignore
95
+ app.post(messagePath, async (req, res) => {
96
+ const sessionId = req.query.sessionId;
97
+ setResponseHeaders({
98
+ res,
99
+ headers,
100
+ });
101
+ if (!sessionId) {
102
+ return res.status(400).send('Missing sessionId parameter');
103
+ }
104
+ const session = sessions[sessionId];
105
+ if (session?.transport?.handlePostMessage) {
106
+ logger.info(`POST to SSE transport (session ${sessionId})`);
107
+ await session.transport.handlePostMessage(req, res);
108
+ }
109
+ else {
110
+ res.status(503).send(`No active SSE connection for session ${sessionId}`);
111
+ }
112
+ });
113
+ app.listen(port, () => {
114
+ logger.info(`Listening on port ${port}`);
115
+ logger.info(`SSE endpoint: http://localhost:${port}${ssePath}`);
116
+ logger.info(`POST messages: http://localhost:${port}${messagePath}`);
117
+ });
118
+ let buffer = '';
119
+ child.stdout.on('data', (chunk) => {
120
+ buffer += chunk.toString('utf8');
121
+ const lines = buffer.split(/\r?\n/);
122
+ buffer = lines.pop() ?? '';
123
+ lines.forEach((line) => {
124
+ if (!line.trim())
125
+ return;
126
+ try {
127
+ const jsonMsg = JSON.parse(line);
128
+ logger.info('Child → SSE:', jsonMsg);
129
+ for (const [sid, session] of Object.entries(sessions)) {
130
+ try {
131
+ session.transport.send(jsonMsg);
132
+ }
133
+ catch (err) {
134
+ logger.error(`Failed to send to session ${sid}:`, err);
135
+ delete sessions[sid];
136
+ }
137
+ }
138
+ }
139
+ catch {
140
+ logger.error(`Child non-JSON: ${line}`);
141
+ }
142
+ });
143
+ });
144
+ child.stderr.on('data', (chunk) => {
145
+ logger.error(`Child stderr: ${chunk.toString('utf8')}`);
146
+ });
147
+ }
@@ -0,0 +1,188 @@
1
+ import express from 'express';
2
+ import cors 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 { getVersion } from '../lib/getVersion.js';
7
+ import { onSignals } from '../lib/onSignals.js';
8
+ import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
11
+ import { SessionAccessCounter } from '../lib/sessionAccessCounter.js';
12
+ const setResponseHeaders = ({ res, headers, }) => Object.entries(headers).forEach(([key, value]) => {
13
+ res.setHeader(key, value);
14
+ });
15
+ export async function stdioToStatefulStreamableHttp(args) {
16
+ const { stdioCmd, port, streamableHttpPath, logger, corsOrigin, healthEndpoints, headers, sessionTimeout, } = args;
17
+ logger.info(` - Headers: ${Object(headers).length ? JSON.stringify(headers) : '(none)'}`);
18
+ logger.info(` - port: ${port}`);
19
+ logger.info(` - stdio: ${stdioCmd}`);
20
+ logger.info(` - streamableHttpPath: ${streamableHttpPath}`);
21
+ logger.info(` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`);
22
+ logger.info(` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`);
23
+ logger.info(` - Session timeout: ${sessionTimeout ? `${sessionTimeout}ms` : 'disabled'}`);
24
+ onSignals({ logger });
25
+ const app = express();
26
+ app.use(express.json());
27
+ if (corsOrigin) {
28
+ app.use(cors({
29
+ origin: corsOrigin,
30
+ exposedHeaders: ['Mcp-Session-Id'],
31
+ }));
32
+ }
33
+ for (const ep of healthEndpoints) {
34
+ app.get(ep, (_req, res) => {
35
+ setResponseHeaders({
36
+ res,
37
+ headers,
38
+ });
39
+ res.send('ok');
40
+ });
41
+ }
42
+ // Map to store transports by session ID
43
+ const transports = {};
44
+ // Session access counter for timeout management
45
+ const sessionCounter = sessionTimeout
46
+ ? new SessionAccessCounter(sessionTimeout, (sessionId) => {
47
+ logger.info(`Session ${sessionId} timed out, cleaning up`);
48
+ const transport = transports[sessionId];
49
+ if (transport) {
50
+ transport.close();
51
+ }
52
+ delete transports[sessionId];
53
+ }, logger)
54
+ : null;
55
+ // Handle POST requests for client-to-server communication
56
+ app.post(streamableHttpPath, async (req, res) => {
57
+ // Check for existing session ID
58
+ const sessionId = req.headers['mcp-session-id'];
59
+ let transport;
60
+ if (sessionId && transports[sessionId]) {
61
+ // Reuse existing transport
62
+ transport = transports[sessionId];
63
+ // Increment session access count
64
+ sessionCounter?.inc(sessionId, 'POST request for existing session');
65
+ }
66
+ else if (!sessionId && isInitializeRequest(req.body)) {
67
+ // New initialization request
68
+ const server = new Server({ name: 'supergateway', version: getVersion() }, { capabilities: {} });
69
+ transport = new StreamableHTTPServerTransport({
70
+ sessionIdGenerator: () => randomUUID(),
71
+ onsessioninitialized: (sessionId) => {
72
+ // Store the transport by session ID
73
+ transports[sessionId] = transport;
74
+ // Initialize session access count
75
+ sessionCounter?.inc(sessionId, 'session initialization');
76
+ },
77
+ });
78
+ await server.connect(transport);
79
+ const child = spawn(stdioCmd, { shell: true });
80
+ child.on('exit', (code, signal) => {
81
+ logger.error(`Child exited: code=${code}, signal=${signal}`);
82
+ transport.close();
83
+ });
84
+ let buffer = '';
85
+ child.stdout.on('data', (chunk) => {
86
+ buffer += chunk.toString('utf8');
87
+ const lines = buffer.split(/\r?\n/);
88
+ buffer = lines.pop() ?? '';
89
+ lines.forEach((line) => {
90
+ if (!line.trim())
91
+ return;
92
+ try {
93
+ const jsonMsg = JSON.parse(line);
94
+ logger.info('Child → StreamableHttp:', line);
95
+ try {
96
+ transport.send(jsonMsg);
97
+ }
98
+ catch (e) {
99
+ logger.error(`Failed to send to StreamableHttp`, e);
100
+ }
101
+ }
102
+ catch {
103
+ logger.error(`Child non-JSON: ${line}`);
104
+ }
105
+ });
106
+ });
107
+ child.stderr.on('data', (chunk) => {
108
+ logger.error(`Child stderr: ${chunk.toString('utf8')}`);
109
+ });
110
+ transport.onmessage = (msg) => {
111
+ logger.info(`StreamableHttp → Child: ${JSON.stringify(msg)}`);
112
+ child.stdin.write(JSON.stringify(msg) + '\n');
113
+ };
114
+ transport.onclose = () => {
115
+ logger.info(`StreamableHttp connection closed (session ${sessionId})`);
116
+ if (transport.sessionId) {
117
+ sessionCounter?.clear(transport.sessionId, false, 'transport being closed');
118
+ delete transports[transport.sessionId];
119
+ }
120
+ child.kill();
121
+ };
122
+ transport.onerror = (err) => {
123
+ logger.error(`StreamableHttp error (session ${sessionId}):`, err);
124
+ if (transport.sessionId) {
125
+ sessionCounter?.clear(transport.sessionId, false, 'transport emitting error');
126
+ delete transports[transport.sessionId];
127
+ }
128
+ child.kill();
129
+ };
130
+ }
131
+ else {
132
+ // Invalid request
133
+ res.status(400).json({
134
+ jsonrpc: '2.0',
135
+ error: {
136
+ code: -32000,
137
+ message: 'Bad Request: No valid session ID provided',
138
+ },
139
+ id: null,
140
+ });
141
+ return;
142
+ }
143
+ // Decrement session access count when response ends
144
+ let responseEnded = false;
145
+ const handleResponseEnd = (event) => {
146
+ if (!responseEnded && transport.sessionId) {
147
+ responseEnded = true;
148
+ logger.info(`Response ${event}`, transport.sessionId);
149
+ sessionCounter?.dec(transport.sessionId, `POST response ${event}`);
150
+ }
151
+ };
152
+ res.on('finish', () => handleResponseEnd('finished'));
153
+ res.on('close', () => handleResponseEnd('closed'));
154
+ // Handle the request
155
+ await transport.handleRequest(req, res, req.body);
156
+ });
157
+ // Reusable handler for GET and DELETE requests
158
+ const handleSessionRequest = async (req, res) => {
159
+ const sessionId = req.headers['mcp-session-id'];
160
+ if (!sessionId || !transports[sessionId]) {
161
+ res.status(400).send('Invalid or missing session ID');
162
+ return;
163
+ }
164
+ // Increment session access count
165
+ sessionCounter?.inc(sessionId, `${req.method} request for existing session`);
166
+ // Decrement session access count when response ends
167
+ let responseEnded = false;
168
+ const handleResponseEnd = (event) => {
169
+ if (!responseEnded) {
170
+ responseEnded = true;
171
+ logger.info(`Response ${event}`, sessionId);
172
+ sessionCounter?.dec(sessionId, `${req.method} response ${event}`);
173
+ }
174
+ };
175
+ res.on('finish', () => handleResponseEnd('finished'));
176
+ res.on('close', () => handleResponseEnd('closed'));
177
+ const transport = transports[sessionId];
178
+ await transport.handleRequest(req, res);
179
+ };
180
+ // Handle GET requests for server-to-client notifications via SSE
181
+ app.get(streamableHttpPath, handleSessionRequest);
182
+ // Handle DELETE requests for session termination
183
+ app.delete(streamableHttpPath, handleSessionRequest);
184
+ app.listen(port, () => {
185
+ logger.info(`Listening on port ${port}`);
186
+ logger.info(`StreamableHttp endpoint: http://localhost:${port}${streamableHttpPath}`);
187
+ });
188
+ }
@@ -0,0 +1,208 @@
1
+ import express from 'express';
2
+ import cors 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 { isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js';
7
+ import { getVersion } from '../lib/getVersion.js';
8
+ import { onSignals } from '../lib/onSignals.js';
9
+ import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js';
10
+ const setResponseHeaders = ({ res, headers, }) => Object.entries(headers).forEach(([key, value]) => {
11
+ res.setHeader(key, value);
12
+ });
13
+ // Helper function to create initialize request
14
+ const createInitializeRequest = (id, protocolVersion) => ({
15
+ jsonrpc: '2.0',
16
+ id,
17
+ method: 'initialize',
18
+ params: {
19
+ protocolVersion,
20
+ capabilities: {
21
+ roots: {
22
+ listChanged: true,
23
+ },
24
+ sampling: {},
25
+ },
26
+ clientInfo: {
27
+ name: 'supergateway',
28
+ version: getVersion(),
29
+ },
30
+ },
31
+ });
32
+ // Helper function to create initialized notification
33
+ const createInitializedNotification = () => ({
34
+ jsonrpc: '2.0',
35
+ method: 'notifications/initialized',
36
+ });
37
+ export async function stdioToStatelessStreamableHttp(args) {
38
+ const { stdioCmd, port, streamableHttpPath, logger, corsOrigin, healthEndpoints, headers, protocolVersion, } = args;
39
+ logger.info(` - Headers: ${Object(headers).length ? JSON.stringify(headers) : '(none)'}`);
40
+ logger.info(` - port: ${port}`);
41
+ logger.info(` - stdio: ${stdioCmd}`);
42
+ logger.info(` - streamableHttpPath: ${streamableHttpPath}`);
43
+ logger.info(` - protocolVersion: ${protocolVersion}`);
44
+ logger.info(` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`);
45
+ logger.info(` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`);
46
+ onSignals({ logger });
47
+ const app = express();
48
+ app.use(express.json());
49
+ if (corsOrigin) {
50
+ app.use(cors({ origin: corsOrigin }));
51
+ }
52
+ for (const ep of healthEndpoints) {
53
+ app.get(ep, (_req, res) => {
54
+ setResponseHeaders({
55
+ res,
56
+ headers,
57
+ });
58
+ res.send('ok');
59
+ });
60
+ }
61
+ app.post(streamableHttpPath, async (req, res) => {
62
+ // In stateless mode, create a new instance of transport and server for each request
63
+ // to ensure complete isolation. A single instance would cause request ID collisions
64
+ // when multiple clients connect concurrently.
65
+ try {
66
+ const server = new Server({ name: 'supergateway', version: getVersion() }, { capabilities: {} });
67
+ const transport = new StreamableHTTPServerTransport({
68
+ sessionIdGenerator: undefined,
69
+ });
70
+ await server.connect(transport);
71
+ const child = spawn(stdioCmd, { shell: true });
72
+ child.on('exit', (code, signal) => {
73
+ logger.error(`Child exited: code=${code}, signal=${signal}`);
74
+ transport.close();
75
+ });
76
+ // State tracking for initialization flow
77
+ let isInitialized = false;
78
+ let initializeRequestId = null; // Current initialize request ID
79
+ let isAutoInitializing = false; // Flag to indicate if we're auto-initializing
80
+ let pendingOriginalMessage = null;
81
+ let buffer = '';
82
+ child.stdout.on('data', (chunk) => {
83
+ buffer += chunk.toString('utf8');
84
+ const lines = buffer.split(/\r?\n/);
85
+ buffer = lines.pop() ?? '';
86
+ lines.forEach((line) => {
87
+ if (!line.trim())
88
+ return;
89
+ try {
90
+ const jsonMsg = JSON.parse(line);
91
+ logger.info('Child → StreamableHttp:', line);
92
+ // Handle initialize response (both auto and client initiated)
93
+ if (initializeRequestId && jsonMsg.id === initializeRequestId) {
94
+ logger.info('Initialize response received');
95
+ isInitialized = true;
96
+ // If this was our auto-initialization, send initialized notification and pending message
97
+ if (isAutoInitializing) {
98
+ // Send initialized notification
99
+ const initializedNotification = createInitializedNotification();
100
+ logger.info(`StreamableHttp → Child (initialized): ${JSON.stringify(initializedNotification)}`);
101
+ child.stdin.write(JSON.stringify(initializedNotification) + '\n');
102
+ // Now send the original message
103
+ if (pendingOriginalMessage) {
104
+ logger.info(`StreamableHttp → Child (original): ${JSON.stringify(pendingOriginalMessage)}`);
105
+ child.stdin.write(JSON.stringify(pendingOriginalMessage) + '\n');
106
+ pendingOriginalMessage = null;
107
+ }
108
+ // Reset auto-initialize tracking
109
+ isAutoInitializing = false;
110
+ initializeRequestId = null;
111
+ // Don't forward our auto-initialize response to the client
112
+ return;
113
+ }
114
+ else {
115
+ // Client-initiated initialize response, just reset tracking
116
+ initializeRequestId = null;
117
+ }
118
+ }
119
+ try {
120
+ transport.send(jsonMsg);
121
+ }
122
+ catch (e) {
123
+ logger.error(`Failed to send to StreamableHttp`, e);
124
+ }
125
+ }
126
+ catch {
127
+ logger.error(`Child non-JSON: ${line}`);
128
+ }
129
+ });
130
+ });
131
+ child.stderr.on('data', (chunk) => {
132
+ logger.error(`Child stderr: ${chunk.toString('utf8')}`);
133
+ });
134
+ transport.onmessage = (msg) => {
135
+ logger.info(`StreamableHttp → Child: ${JSON.stringify(msg)}`);
136
+ // Check if we need to auto-initialize first
137
+ if (!isInitialized && !isInitializeRequest(msg)) {
138
+ // Store the original message and send initialize first
139
+ pendingOriginalMessage = msg;
140
+ initializeRequestId = `init_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
141
+ isAutoInitializing = true;
142
+ logger.info('Non-initialize message detected, sending auto-initialize request first');
143
+ const initRequest = createInitializeRequest(initializeRequestId, protocolVersion);
144
+ logger.info(`StreamableHttp → Child (auto-initialize): ${JSON.stringify(initRequest)}`);
145
+ child.stdin.write(JSON.stringify(initRequest) + '\n');
146
+ // Don't send the original message yet - it will be sent after initialization
147
+ return;
148
+ }
149
+ // Track initialize request ID (both client and auto)
150
+ if (isInitializeRequest(msg) && 'id' in msg && msg.id !== undefined) {
151
+ initializeRequestId = msg.id;
152
+ isAutoInitializing = false; // This is client-initiated
153
+ logger.info(`Tracking initialize request ID: ${msg.id}`);
154
+ }
155
+ // Send all messages to child process normally
156
+ child.stdin.write(JSON.stringify(msg) + '\n');
157
+ };
158
+ transport.onclose = () => {
159
+ logger.info('StreamableHttp connection closed');
160
+ child.kill();
161
+ };
162
+ transport.onerror = (err) => {
163
+ logger.error(`StreamableHttp error:`, err);
164
+ child.kill();
165
+ };
166
+ await transport.handleRequest(req, res, req.body);
167
+ }
168
+ catch (error) {
169
+ logger.error('Error handling MCP request:', error);
170
+ if (!res.headersSent) {
171
+ res.status(500).json({
172
+ jsonrpc: '2.0',
173
+ error: {
174
+ code: -32603,
175
+ message: 'Internal server error',
176
+ },
177
+ id: null,
178
+ });
179
+ }
180
+ }
181
+ });
182
+ app.get(streamableHttpPath, async (req, res) => {
183
+ logger.info('Received GET MCP request');
184
+ res.writeHead(405).end(JSON.stringify({
185
+ jsonrpc: '2.0',
186
+ error: {
187
+ code: -32000,
188
+ message: 'Method not allowed.',
189
+ },
190
+ id: null,
191
+ }));
192
+ });
193
+ app.delete(streamableHttpPath, async (req, res) => {
194
+ logger.info('Received DELETE MCP request');
195
+ res.writeHead(405).end(JSON.stringify({
196
+ jsonrpc: '2.0',
197
+ error: {
198
+ code: -32000,
199
+ message: 'Method not allowed.',
200
+ },
201
+ id: null,
202
+ }));
203
+ });
204
+ app.listen(port, () => {
205
+ logger.info(`Listening on port ${port}`);
206
+ logger.info(`StreamableHttp endpoint: http://localhost:${port}${streamableHttpPath}`);
207
+ });
208
+ }
@@ -0,0 +1,113 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { createServer } from 'http';
4
+ import { spawn } from 'child_process';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { getVersion } from '../lib/getVersion.js';
7
+ import { WebSocketServerTransport } from '../server/websocket.js';
8
+ import { onSignals } from '../lib/onSignals.js';
9
+ import { serializeCorsOrigin } from '../lib/serializeCorsOrigin.js';
10
+ export async function stdioToWs(args) {
11
+ const { stdioCmd, port, messagePath, logger, healthEndpoints, corsOrigin } = args;
12
+ logger.info(` - port: ${port}`);
13
+ logger.info(` - stdio: ${stdioCmd}`);
14
+ logger.info(` - messagePath: ${messagePath}`);
15
+ logger.info(` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin({ corsOrigin })})` : 'disabled'}`);
16
+ logger.info(` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`);
17
+ let wsTransport = null;
18
+ let child = null;
19
+ let isReady = false;
20
+ const cleanup = () => {
21
+ if (wsTransport) {
22
+ wsTransport.close().catch((err) => {
23
+ logger.error(`Error stopping WebSocket server: ${err.message}`);
24
+ });
25
+ }
26
+ if (child) {
27
+ child.kill();
28
+ }
29
+ };
30
+ onSignals({
31
+ logger,
32
+ cleanup,
33
+ });
34
+ try {
35
+ child = spawn(stdioCmd, { shell: true });
36
+ child.on('exit', (code, signal) => {
37
+ logger.error(`Child exited: code=${code}, signal=${signal}`);
38
+ cleanup();
39
+ process.exit(code ?? 1);
40
+ });
41
+ const server = new Server({ name: 'supergateway', version: getVersion() }, { capabilities: {} });
42
+ // Handle child process output
43
+ let buffer = '';
44
+ child.stdout.on('data', (chunk) => {
45
+ buffer += chunk.toString('utf8');
46
+ const lines = buffer.split(/\r?\n/);
47
+ buffer = lines.pop() ?? '';
48
+ lines.forEach((line) => {
49
+ if (!line.trim())
50
+ return;
51
+ try {
52
+ const jsonMsg = JSON.parse(line);
53
+ logger.info(`Child → WebSocket: ${JSON.stringify(jsonMsg)}`);
54
+ // Broadcast to all connected clients
55
+ wsTransport?.send(jsonMsg, jsonMsg.id).catch((err) => {
56
+ logger.error('Failed to broadcast message:', err);
57
+ });
58
+ }
59
+ catch {
60
+ logger.error(`Child non-JSON: ${line}`);
61
+ }
62
+ });
63
+ });
64
+ child.stderr.on('data', (chunk) => {
65
+ logger.info(`Child stderr: ${chunk.toString('utf8')}`);
66
+ });
67
+ const app = express();
68
+ if (corsOrigin) {
69
+ app.use(cors({ origin: corsOrigin }));
70
+ }
71
+ for (const ep of healthEndpoints) {
72
+ app.get(ep, (_req, res) => {
73
+ if (child?.killed) {
74
+ res.status(500).send('Child process has been killed');
75
+ }
76
+ if (!isReady) {
77
+ res.status(500).send('Server is not ready');
78
+ }
79
+ res.send('ok');
80
+ });
81
+ }
82
+ const httpServer = createServer(app);
83
+ wsTransport = new WebSocketServerTransport({
84
+ path: messagePath,
85
+ server: httpServer,
86
+ });
87
+ await server.connect(wsTransport);
88
+ wsTransport.onmessage = (msg) => {
89
+ const line = JSON.stringify(msg);
90
+ logger.info(`WebSocket → Child: ${line}`);
91
+ child.stdin.write(line + '\n');
92
+ };
93
+ wsTransport.onconnection = (clientId) => {
94
+ logger.info(`New WebSocket connection: ${clientId}`);
95
+ };
96
+ wsTransport.ondisconnection = (clientId) => {
97
+ logger.info(`WebSocket connection closed: ${clientId}`);
98
+ };
99
+ wsTransport.onerror = (err) => {
100
+ logger.error(`WebSocket error: ${err.message}`);
101
+ };
102
+ isReady = true;
103
+ httpServer.listen(port, () => {
104
+ logger.info(`Listening on port ${port}`);
105
+ logger.info(`WebSocket endpoint: ws://localhost:${port}${messagePath}`);
106
+ });
107
+ }
108
+ catch (err) {
109
+ logger.error(`Failed to start: ${err.message}`);
110
+ cleanup();
111
+ process.exit(1);
112
+ }
113
+ }