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.
- package/.github/workflows/docker-publish.yaml +79 -0
- package/.husky/pre-commit +17 -0
- package/.prettierignore +8 -0
- package/.prettierrc +5 -0
- package/AGENTS.md +29 -0
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/dist/gateways/sseToStdio.js +139 -0
- package/dist/gateways/stdioToSse.js +147 -0
- package/dist/gateways/stdioToStatefulStreamableHttp.js +188 -0
- package/dist/gateways/stdioToStatelessStreamableHttp.js +208 -0
- package/dist/gateways/stdioToWs.js +113 -0
- package/dist/gateways/streamableHttpToStdio.js +134 -0
- package/dist/index.js +266 -0
- package/dist/lib/corsOrigin.js +23 -0
- package/dist/lib/getLogger.js +44 -0
- package/dist/lib/getVersion.js +16 -0
- package/dist/lib/headers.js +31 -0
- package/dist/lib/onSignals.js +27 -0
- package/dist/lib/serializeCorsOrigin.js +6 -0
- package/dist/lib/sessionAccessCounter.js +77 -0
- package/dist/server/websocket.js +102 -0
- package/dist/services/EncryptionService.js +236 -0
- package/dist/types.js +1 -0
- package/docker/base.Dockerfile +9 -0
- package/docker/deno.Dockerfile +2 -0
- package/docker/uvx.Dockerfile +3 -0
- package/docker-bake.hcl +51 -0
- package/package.json +61 -0
- package/scripts/decrypt-sample.ts +34 -0
- package/scripts/encryption-play.ts +145 -0
- package/src/gateways/sseToStdio.ts +195 -0
- package/src/gateways/stdioToSse.ts +260 -0
- package/src/gateways/stdioToStatefulStreamableHttp.ts +274 -0
- package/src/gateways/stdioToStatelessStreamableHttp.ts +303 -0
- package/src/gateways/stdioToWs.ts +151 -0
- package/src/gateways/streamableHttpToStdio.ts +196 -0
- package/src/index.ts +286 -0
- package/src/lib/corsOrigin.ts +31 -0
- package/src/lib/getLogger.ts +83 -0
- package/src/lib/getVersion.ts +17 -0
- package/src/lib/headers.ts +55 -0
- package/src/lib/initMongoClient.ts +10 -0
- package/src/lib/mcpServerLogRepository.ts +48 -0
- package/src/lib/onSignals.ts +39 -0
- package/src/lib/serializeCorsOrigin.ts +14 -0
- package/src/lib/sessionAccessCounter.ts +118 -0
- package/src/server/websocket.ts +121 -0
- package/src/services/encryptionService.ts +309 -0
- package/src/types.ts +4 -0
- package/supergateway.png +0 -0
- package/tests/baseUrl.test.ts +62 -0
- package/tests/concurrency.test.ts +137 -0
- package/tests/helpers/mock-mcp-server.js +94 -0
- package/tests/protocolVersion.test.ts +60 -0
- package/tests/stdioToStatefulStreamableHttp.test.ts +70 -0
- package/tests/stdioToStatelessStreamableHttp.test.ts +71 -0
- package/tests/streamableHttpCli.test.ts +24 -0
- package/tests/streamableHttpToStdio.test.ts +64 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +12 -0
- 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
|
+
}
|