@platf/bridge 0.0.2
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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/gateways/statefulBridge.d.ts +22 -0
- package/dist/gateways/statefulBridge.js +211 -0
- package/dist/gateways/statefulBridge.js.map +1 -0
- package/dist/gateways/statelessBridge.d.ts +22 -0
- package/dist/gateways/statelessBridge.js +234 -0
- package/dist/gateways/statelessBridge.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +117 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/authMiddleware.d.ts +12 -0
- package/dist/lib/authMiddleware.js +41 -0
- package/dist/lib/authMiddleware.js.map +1 -0
- package/dist/lib/cors.d.ts +3 -0
- package/dist/lib/cors.js +29 -0
- package/dist/lib/cors.js.map +1 -0
- package/dist/lib/discoveryRoutes.d.ts +14 -0
- package/dist/lib/discoveryRoutes.js +86 -0
- package/dist/lib/discoveryRoutes.js.map +1 -0
- package/dist/lib/getLogger.d.ts +3 -0
- package/dist/lib/getLogger.js +34 -0
- package/dist/lib/getLogger.js.map +1 -0
- package/dist/lib/headers.d.ts +2 -0
- package/dist/lib/headers.js +19 -0
- package/dist/lib/headers.js.map +1 -0
- package/dist/lib/onSignals.d.ts +6 -0
- package/dist/lib/onSignals.js +16 -0
- package/dist/lib/onSignals.js.map +1 -0
- package/dist/lib/sessionAccessCounter.d.ts +11 -0
- package/dist/lib/sessionAccessCounter.js +72 -0
- package/dist/lib/sessionAccessCounter.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +41 -0
- package/src/gateways/statefulBridge.ts +271 -0
- package/src/gateways/statelessBridge.ts +310 -0
- package/src/index.ts +121 -0
- package/src/lib/authMiddleware.ts +58 -0
- package/src/lib/cors.ts +30 -0
- package/src/lib/discoveryRoutes.ts +95 -0
- package/src/lib/getLogger.ts +49 -0
- package/src/lib/headers.ts +26 -0
- package/src/lib/onSignals.ts +24 -0
- package/src/lib/sessionAccessCounter.ts +98 -0
- package/src/types.ts +12 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import cors, { type CorsOptions } from 'cors'
|
|
3
|
+
import { spawn } from 'child_process'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
7
|
+
import { type JSONRPCMessage, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
|
|
8
|
+
import type { AuthConfig, Logger } from '../types.js'
|
|
9
|
+
import { onSignals } from '../lib/onSignals.js'
|
|
10
|
+
import { serializeCorsOrigin } from '../lib/cors.js'
|
|
11
|
+
import { SessionAccessCounter } from '../lib/sessionAccessCounter.js'
|
|
12
|
+
import { createAuthMiddleware } from '../lib/authMiddleware.js'
|
|
13
|
+
import { createDiscoveryRouter } from '../lib/discoveryRoutes.js'
|
|
14
|
+
|
|
15
|
+
const VERSION = '1.0.0'
|
|
16
|
+
|
|
17
|
+
export interface StatefulBridgeArgs {
|
|
18
|
+
stdioCmd: string
|
|
19
|
+
port: number
|
|
20
|
+
path: string
|
|
21
|
+
logger: Logger
|
|
22
|
+
corsOrigin: CorsOptions['origin']
|
|
23
|
+
healthEndpoints: string[]
|
|
24
|
+
headers: Record<string, string>
|
|
25
|
+
sessionTimeout: number | null
|
|
26
|
+
auth: AuthConfig | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const setResponseHeaders = (res: express.Response, headers: Record<string, string>) =>
|
|
30
|
+
Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value))
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Stateful stdio-to-Streamable HTTP bridge.
|
|
34
|
+
*
|
|
35
|
+
* Maintains session state via `Mcp-Session-Id` header. Each session
|
|
36
|
+
* spawns one child process; subsequent requests for the same session
|
|
37
|
+
* reuse the existing transport and process. Sessions are cleaned up
|
|
38
|
+
* after an optional inactivity timeout.
|
|
39
|
+
*/
|
|
40
|
+
export async function startStatefulBridge(args: StatefulBridgeArgs) {
|
|
41
|
+
const {
|
|
42
|
+
stdioCmd,
|
|
43
|
+
port,
|
|
44
|
+
path,
|
|
45
|
+
logger,
|
|
46
|
+
corsOrigin,
|
|
47
|
+
healthEndpoints,
|
|
48
|
+
headers,
|
|
49
|
+
sessionTimeout,
|
|
50
|
+
auth,
|
|
51
|
+
} = args
|
|
52
|
+
|
|
53
|
+
logger.info(`[stateful] Starting platf-bridge`)
|
|
54
|
+
logger.info(` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`)
|
|
55
|
+
logger.info(` - port: ${port}`)
|
|
56
|
+
logger.info(` - stdio: ${stdioCmd}`)
|
|
57
|
+
logger.info(` - path: ${path}`)
|
|
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(` - Session timeout: ${sessionTimeout ? `${sessionTimeout}ms` : 'disabled'}`)
|
|
65
|
+
|
|
66
|
+
onSignals({ logger })
|
|
67
|
+
|
|
68
|
+
const app = express()
|
|
69
|
+
app.use(express.json())
|
|
70
|
+
|
|
71
|
+
if (corsOrigin) {
|
|
72
|
+
app.use(cors({ origin: corsOrigin, exposedHeaders: ['Mcp-Session-Id'] }))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const ep of healthEndpoints) {
|
|
76
|
+
app.get(ep, (_req, res) => {
|
|
77
|
+
setResponseHeaders(res, headers)
|
|
78
|
+
res.send('ok')
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- OAuth discovery & auth middleware (when auth is enabled) ---
|
|
83
|
+
if (auth) {
|
|
84
|
+
app.use(createDiscoveryRouter(auth, logger))
|
|
85
|
+
app.use(path, createAuthMiddleware(auth, logger))
|
|
86
|
+
logger.info(` - Auth: enabled (issuer=${auth.issuer})`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Session state
|
|
90
|
+
const transports: Record<string, StreamableHTTPServerTransport> = {}
|
|
91
|
+
|
|
92
|
+
const sessionCounter = sessionTimeout
|
|
93
|
+
? new SessionAccessCounter(
|
|
94
|
+
sessionTimeout,
|
|
95
|
+
(sessionId) => {
|
|
96
|
+
logger.info(`Session ${sessionId} timed out, cleaning up`)
|
|
97
|
+
const transport = transports[sessionId]
|
|
98
|
+
if (transport) transport.close()
|
|
99
|
+
delete transports[sessionId]
|
|
100
|
+
},
|
|
101
|
+
logger,
|
|
102
|
+
)
|
|
103
|
+
: null
|
|
104
|
+
|
|
105
|
+
// --- POST handler ---
|
|
106
|
+
app.post(path, async (req, res) => {
|
|
107
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
108
|
+
let transport: StreamableHTTPServerTransport
|
|
109
|
+
|
|
110
|
+
if (sessionId && transports[sessionId]) {
|
|
111
|
+
// Reuse existing session
|
|
112
|
+
transport = transports[sessionId]
|
|
113
|
+
sessionCounter?.inc(sessionId, 'POST request for existing session')
|
|
114
|
+
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
115
|
+
// New session — spawn child process
|
|
116
|
+
const server = new Server(
|
|
117
|
+
{ name: 'platf-bridge', version: VERSION },
|
|
118
|
+
{ capabilities: {} },
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
transport = new StreamableHTTPServerTransport({
|
|
122
|
+
sessionIdGenerator: () => randomUUID(),
|
|
123
|
+
onsessioninitialized: (newSessionId) => {
|
|
124
|
+
transports[newSessionId] = transport
|
|
125
|
+
sessionCounter?.inc(newSessionId, 'session initialization')
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await server.connect(transport)
|
|
130
|
+
const child = spawn(stdioCmd, { shell: true })
|
|
131
|
+
|
|
132
|
+
const pendingRequestIds = new Set<string | number>()
|
|
133
|
+
let stderrOutput = ''
|
|
134
|
+
|
|
135
|
+
child.on('exit', (code, signal) => {
|
|
136
|
+
logger.error(`Child exited: code=${code}, signal=${signal}`)
|
|
137
|
+
|
|
138
|
+
// Send JSON-RPC error responses for all pending requests
|
|
139
|
+
for (const id of pendingRequestIds) {
|
|
140
|
+
const detail = stderrOutput.trim().slice(0, 1000)
|
|
141
|
+
const message = detail
|
|
142
|
+
? `Child process exited (code=${code}): ${detail}`
|
|
143
|
+
: `Child process exited unexpectedly (code=${code}, signal=${signal})`
|
|
144
|
+
try {
|
|
145
|
+
transport.send({
|
|
146
|
+
jsonrpc: '2.0',
|
|
147
|
+
error: { code: -32603, message },
|
|
148
|
+
id,
|
|
149
|
+
} as JSONRPCMessage)
|
|
150
|
+
} catch (e) {
|
|
151
|
+
logger.error(`Failed to send error response for request ${id}`, e)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
pendingRequestIds.clear()
|
|
155
|
+
|
|
156
|
+
transport.close()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
let buffer = ''
|
|
160
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
161
|
+
buffer += chunk.toString('utf8')
|
|
162
|
+
const lines = buffer.split(/\r?\n/)
|
|
163
|
+
buffer = lines.pop() ?? ''
|
|
164
|
+
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
if (!line.trim()) continue
|
|
167
|
+
try {
|
|
168
|
+
const jsonMsg = JSON.parse(line)
|
|
169
|
+
if ('id' in jsonMsg && jsonMsg.id !== undefined) {
|
|
170
|
+
pendingRequestIds.delete(jsonMsg.id)
|
|
171
|
+
}
|
|
172
|
+
logger.info('Child → HTTP:', line)
|
|
173
|
+
try {
|
|
174
|
+
transport.send(jsonMsg)
|
|
175
|
+
} catch (e) {
|
|
176
|
+
logger.error('Failed to send to HTTP transport', e)
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
logger.error(`Child non-JSON: ${line}`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
185
|
+
const text = chunk.toString('utf8')
|
|
186
|
+
stderrOutput += text
|
|
187
|
+
logger.error(`Child stderr: ${text}`)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
transport.onmessage = (msg: JSONRPCMessage) => {
|
|
191
|
+
logger.info(`HTTP → Child: ${JSON.stringify(msg)}`)
|
|
192
|
+
if ('id' in msg && msg.id !== undefined) {
|
|
193
|
+
pendingRequestIds.add(msg.id as string | number)
|
|
194
|
+
}
|
|
195
|
+
child.stdin.write(JSON.stringify(msg) + '\n')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
transport.onclose = () => {
|
|
199
|
+
logger.info(`HTTP connection closed (session ${sessionId})`)
|
|
200
|
+
if (transport.sessionId) {
|
|
201
|
+
sessionCounter?.clear(transport.sessionId, false, 'transport closed')
|
|
202
|
+
delete transports[transport.sessionId]
|
|
203
|
+
}
|
|
204
|
+
child.kill()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
transport.onerror = (err) => {
|
|
208
|
+
logger.error(`HTTP transport error (session ${sessionId}):`, err)
|
|
209
|
+
if (transport.sessionId) {
|
|
210
|
+
sessionCounter?.clear(transport.sessionId, false, 'transport error')
|
|
211
|
+
delete transports[transport.sessionId]
|
|
212
|
+
}
|
|
213
|
+
child.kill()
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
res.status(400).json({
|
|
217
|
+
jsonrpc: '2.0',
|
|
218
|
+
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
|
|
219
|
+
id: null,
|
|
220
|
+
})
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Track response lifecycle for session cleanup
|
|
225
|
+
let responseEnded = false
|
|
226
|
+
const handleResponseEnd = (event: string) => {
|
|
227
|
+
if (!responseEnded && transport.sessionId) {
|
|
228
|
+
responseEnded = true
|
|
229
|
+
logger.info(`Response ${event}`, transport.sessionId)
|
|
230
|
+
sessionCounter?.dec(transport.sessionId, `POST response ${event}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
res.on('finish', () => handleResponseEnd('finished'))
|
|
234
|
+
res.on('close', () => handleResponseEnd('closed'))
|
|
235
|
+
|
|
236
|
+
await transport.handleRequest(req, res, req.body)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// --- GET / DELETE handler (session-bound) ---
|
|
240
|
+
const handleSessionRequest = async (req: express.Request, res: express.Response) => {
|
|
241
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
242
|
+
if (!sessionId || !transports[sessionId]) {
|
|
243
|
+
res.status(400).send('Invalid or missing session ID')
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
sessionCounter?.inc(sessionId, `${req.method} request for existing session`)
|
|
248
|
+
|
|
249
|
+
let responseEnded = false
|
|
250
|
+
const handleResponseEnd = (event: string) => {
|
|
251
|
+
if (!responseEnded) {
|
|
252
|
+
responseEnded = true
|
|
253
|
+
logger.info(`Response ${event}`, sessionId)
|
|
254
|
+
sessionCounter?.dec(sessionId, `${req.method} response ${event}`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
res.on('finish', () => handleResponseEnd('finished'))
|
|
258
|
+
res.on('close', () => handleResponseEnd('closed'))
|
|
259
|
+
|
|
260
|
+
const transport = transports[sessionId]
|
|
261
|
+
await transport.handleRequest(req, res)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
app.get(path, handleSessionRequest)
|
|
265
|
+
app.delete(path, handleSessionRequest)
|
|
266
|
+
|
|
267
|
+
app.listen(port, () => {
|
|
268
|
+
logger.info(`Listening on port ${port}`)
|
|
269
|
+
logger.info(`Streamable HTTP endpoint: http://localhost:${port}${path}`)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
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
|
+
type JSONRPCMessage,
|
|
8
|
+
isInitializeRequest,
|
|
9
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
10
|
+
import type { AuthConfig, Logger } from '../types.js'
|
|
11
|
+
import { onSignals } from '../lib/onSignals.js'
|
|
12
|
+
import { serializeCorsOrigin } from '../lib/cors.js'
|
|
13
|
+
import { createAuthMiddleware } from '../lib/authMiddleware.js'
|
|
14
|
+
import { createDiscoveryRouter } from '../lib/discoveryRoutes.js'
|
|
15
|
+
|
|
16
|
+
const VERSION = '1.0.0'
|
|
17
|
+
|
|
18
|
+
export interface StatelessBridgeArgs {
|
|
19
|
+
stdioCmd: string
|
|
20
|
+
port: number
|
|
21
|
+
path: string
|
|
22
|
+
logger: Logger
|
|
23
|
+
corsOrigin: CorsOptions['origin']
|
|
24
|
+
healthEndpoints: string[]
|
|
25
|
+
headers: Record<string, string>
|
|
26
|
+
protocolVersion: string
|
|
27
|
+
auth: AuthConfig | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const setResponseHeaders = (res: express.Response, headers: Record<string, string>) =>
|
|
31
|
+
Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value))
|
|
32
|
+
|
|
33
|
+
/** Create a synthetic MCP initialize request */
|
|
34
|
+
const createInitializeRequest = (
|
|
35
|
+
id: string | number,
|
|
36
|
+
protocolVersion: string,
|
|
37
|
+
): JSONRPCMessage => ({
|
|
38
|
+
jsonrpc: '2.0',
|
|
39
|
+
id,
|
|
40
|
+
method: 'initialize',
|
|
41
|
+
params: {
|
|
42
|
+
protocolVersion,
|
|
43
|
+
capabilities: {
|
|
44
|
+
roots: { listChanged: true },
|
|
45
|
+
sampling: {},
|
|
46
|
+
},
|
|
47
|
+
clientInfo: {
|
|
48
|
+
name: 'platf-bridge',
|
|
49
|
+
version: VERSION,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const createInitializedNotification = (): JSONRPCMessage => ({
|
|
55
|
+
jsonrpc: '2.0',
|
|
56
|
+
method: 'notifications/initialized',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stateless stdio-to-Streamable HTTP bridge.
|
|
61
|
+
*
|
|
62
|
+
* For every incoming POST request, spawns a fresh child process,
|
|
63
|
+
* auto-initializes it if the request is not an initialize request,
|
|
64
|
+
* and proxies JSON-RPC messages between the HTTP transport and the
|
|
65
|
+
* child's stdin/stdout.
|
|
66
|
+
*/
|
|
67
|
+
export async function startStatelessBridge(args: StatelessBridgeArgs) {
|
|
68
|
+
const {
|
|
69
|
+
stdioCmd,
|
|
70
|
+
port,
|
|
71
|
+
path,
|
|
72
|
+
logger,
|
|
73
|
+
corsOrigin,
|
|
74
|
+
healthEndpoints,
|
|
75
|
+
headers,
|
|
76
|
+
protocolVersion,
|
|
77
|
+
auth,
|
|
78
|
+
} = args
|
|
79
|
+
|
|
80
|
+
logger.info(`[stateless] Starting platf-bridge`)
|
|
81
|
+
logger.info(` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`)
|
|
82
|
+
logger.info(` - port: ${port}`)
|
|
83
|
+
logger.info(` - stdio: ${stdioCmd}`)
|
|
84
|
+
logger.info(` - path: ${path}`)
|
|
85
|
+
logger.info(` - protocolVersion: ${protocolVersion}`)
|
|
86
|
+
logger.info(
|
|
87
|
+
` - CORS: ${corsOrigin ? `enabled (${serializeCorsOrigin(corsOrigin)})` : 'disabled'}`,
|
|
88
|
+
)
|
|
89
|
+
logger.info(
|
|
90
|
+
` - Health endpoints: ${healthEndpoints.length ? healthEndpoints.join(', ') : '(none)'}`,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
onSignals({ logger })
|
|
94
|
+
|
|
95
|
+
const app = express()
|
|
96
|
+
app.use(express.json())
|
|
97
|
+
|
|
98
|
+
if (corsOrigin) {
|
|
99
|
+
app.use(cors({ origin: corsOrigin }))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const ep of healthEndpoints) {
|
|
103
|
+
app.get(ep, (_req, res) => {
|
|
104
|
+
setResponseHeaders(res, headers)
|
|
105
|
+
res.send('ok')
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- OAuth discovery & auth middleware (when auth is enabled) ---
|
|
110
|
+
if (auth) {
|
|
111
|
+
app.use(createDiscoveryRouter(auth, logger))
|
|
112
|
+
app.use(path, createAuthMiddleware(auth, logger))
|
|
113
|
+
logger.info(` - Auth: enabled (issuer=${auth.issuer})`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
app.post(path, async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const server = new Server(
|
|
119
|
+
{ name: 'platf-bridge', version: VERSION },
|
|
120
|
+
{ capabilities: {} },
|
|
121
|
+
)
|
|
122
|
+
const transport = new StreamableHTTPServerTransport({
|
|
123
|
+
sessionIdGenerator: undefined,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
await server.connect(transport)
|
|
127
|
+
const child = spawn(stdioCmd, { shell: true })
|
|
128
|
+
|
|
129
|
+
const pendingRequestIds = new Set<string | number>()
|
|
130
|
+
let stderrOutput = ''
|
|
131
|
+
|
|
132
|
+
child.on('exit', (code, signal) => {
|
|
133
|
+
logger.error(`Child exited: code=${code}, signal=${signal}`)
|
|
134
|
+
|
|
135
|
+
// Include queued original message's ID if it was never sent to the child
|
|
136
|
+
if (pendingOriginalMessage && 'id' in pendingOriginalMessage && pendingOriginalMessage.id !== undefined) {
|
|
137
|
+
pendingRequestIds.add(pendingOriginalMessage.id as string | number)
|
|
138
|
+
pendingOriginalMessage = null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Remove auto-init ID — the client doesn't expect a response for it
|
|
142
|
+
if (isAutoInitializing && initializeRequestId !== null) {
|
|
143
|
+
pendingRequestIds.delete(initializeRequestId)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Send JSON-RPC error responses for all pending client requests
|
|
147
|
+
for (const id of pendingRequestIds) {
|
|
148
|
+
const detail = stderrOutput.trim().slice(0, 1000)
|
|
149
|
+
const message = detail
|
|
150
|
+
? `Child process exited (code=${code}): ${detail}`
|
|
151
|
+
: `Child process exited unexpectedly (code=${code}, signal=${signal})`
|
|
152
|
+
try {
|
|
153
|
+
transport.send({
|
|
154
|
+
jsonrpc: '2.0',
|
|
155
|
+
error: { code: -32603, message },
|
|
156
|
+
id,
|
|
157
|
+
} as JSONRPCMessage)
|
|
158
|
+
} catch (e) {
|
|
159
|
+
logger.error(`Failed to send error response for request ${id}`, e)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
pendingRequestIds.clear()
|
|
163
|
+
|
|
164
|
+
transport.close()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// --- Auto-initialization state ---
|
|
168
|
+
let isInitialized = false
|
|
169
|
+
let initializeRequestId: string | number | null = null
|
|
170
|
+
let isAutoInitializing = false
|
|
171
|
+
let pendingOriginalMessage: JSONRPCMessage | null = null
|
|
172
|
+
|
|
173
|
+
let buffer = ''
|
|
174
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
175
|
+
buffer += chunk.toString('utf8')
|
|
176
|
+
const lines = buffer.split(/\r?\n/)
|
|
177
|
+
buffer = lines.pop() ?? ''
|
|
178
|
+
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
if (!line.trim()) continue
|
|
181
|
+
try {
|
|
182
|
+
const jsonMsg = JSON.parse(line)
|
|
183
|
+
if ('id' in jsonMsg && jsonMsg.id !== undefined) {
|
|
184
|
+
pendingRequestIds.delete(jsonMsg.id)
|
|
185
|
+
}
|
|
186
|
+
logger.info('Child → HTTP:', line)
|
|
187
|
+
|
|
188
|
+
// Handle initialize response (auto or client-initiated)
|
|
189
|
+
if (initializeRequestId && jsonMsg.id === initializeRequestId) {
|
|
190
|
+
logger.info('Initialize response received')
|
|
191
|
+
isInitialized = true
|
|
192
|
+
|
|
193
|
+
if (isAutoInitializing) {
|
|
194
|
+
// Send initialized notification then the queued original message
|
|
195
|
+
const notification = createInitializedNotification()
|
|
196
|
+
logger.info(`HTTP → Child (initialized): ${JSON.stringify(notification)}`)
|
|
197
|
+
child.stdin.write(JSON.stringify(notification) + '\n')
|
|
198
|
+
|
|
199
|
+
if (pendingOriginalMessage) {
|
|
200
|
+
logger.info(`HTTP → Child (original): ${JSON.stringify(pendingOriginalMessage)}`)
|
|
201
|
+
child.stdin.write(JSON.stringify(pendingOriginalMessage) + '\n')
|
|
202
|
+
pendingOriginalMessage = null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
isAutoInitializing = false
|
|
206
|
+
initializeRequestId = null
|
|
207
|
+
return // don't forward auto-init response to client
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
initializeRequestId = null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
transport.send(jsonMsg)
|
|
215
|
+
} catch (e) {
|
|
216
|
+
logger.error('Failed to send to HTTP transport', e)
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
logger.error(`Child non-JSON: ${line}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
225
|
+
const text = chunk.toString('utf8')
|
|
226
|
+
stderrOutput += text
|
|
227
|
+
logger.error(`Child stderr: ${text}`)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
transport.onmessage = (msg: JSONRPCMessage) => {
|
|
231
|
+
logger.info(`HTTP → Child: ${JSON.stringify(msg)}`)
|
|
232
|
+
|
|
233
|
+
// Track client request IDs for error reporting on child exit
|
|
234
|
+
if ('id' in msg && msg.id !== undefined) {
|
|
235
|
+
pendingRequestIds.add(msg.id as string | number)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Auto-initialize if the first message is not an initialize request
|
|
239
|
+
if (!isInitialized && !isInitializeRequest(msg)) {
|
|
240
|
+
pendingOriginalMessage = msg
|
|
241
|
+
initializeRequestId = `init_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
|
|
242
|
+
isAutoInitializing = true
|
|
243
|
+
|
|
244
|
+
logger.info('Non-initialize message detected, sending auto-initialize first')
|
|
245
|
+
const initReq = createInitializeRequest(initializeRequestId, protocolVersion)
|
|
246
|
+
logger.info(`HTTP → Child (auto-init): ${JSON.stringify(initReq)}`)
|
|
247
|
+
child.stdin.write(JSON.stringify(initReq) + '\n')
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Track client-initiated initialize
|
|
252
|
+
if (isInitializeRequest(msg) && 'id' in msg && msg.id !== undefined) {
|
|
253
|
+
initializeRequestId = msg.id
|
|
254
|
+
isAutoInitializing = false
|
|
255
|
+
logger.info(`Tracking initialize request ID: ${msg.id}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
child.stdin.write(JSON.stringify(msg) + '\n')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
transport.onclose = () => {
|
|
262
|
+
logger.info('HTTP connection closed')
|
|
263
|
+
child.kill()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
transport.onerror = (err) => {
|
|
267
|
+
logger.error('HTTP transport error:', err)
|
|
268
|
+
child.kill()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await transport.handleRequest(req, res, req.body)
|
|
272
|
+
} catch (error) {
|
|
273
|
+
logger.error('Error handling MCP request:', error)
|
|
274
|
+
if (!res.headersSent) {
|
|
275
|
+
res.status(500).json({
|
|
276
|
+
jsonrpc: '2.0',
|
|
277
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
278
|
+
id: null,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
app.get(path, (_req, res) => {
|
|
285
|
+
logger.info('Received GET — method not allowed in stateless mode')
|
|
286
|
+
res.writeHead(405).end(
|
|
287
|
+
JSON.stringify({
|
|
288
|
+
jsonrpc: '2.0',
|
|
289
|
+
error: { code: -32000, message: 'Method not allowed.' },
|
|
290
|
+
id: null,
|
|
291
|
+
}),
|
|
292
|
+
)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
app.delete(path, (_req, res) => {
|
|
296
|
+
logger.info('Received DELETE — method not allowed in stateless mode')
|
|
297
|
+
res.writeHead(405).end(
|
|
298
|
+
JSON.stringify({
|
|
299
|
+
jsonrpc: '2.0',
|
|
300
|
+
error: { code: -32000, message: 'Method not allowed.' },
|
|
301
|
+
id: null,
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
app.listen(port, () => {
|
|
307
|
+
logger.info(`Listening on port ${port}`)
|
|
308
|
+
logger.info(`Streamable HTTP endpoint: http://localhost:${port}${path}`)
|
|
309
|
+
})
|
|
310
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import yargs from 'yargs'
|
|
4
|
+
import { hideBin } from 'yargs/helpers'
|
|
5
|
+
import { getLogger, type LogLevel } from './lib/getLogger.js'
|
|
6
|
+
import { parseCorsOrigin } from './lib/cors.js'
|
|
7
|
+
import { parseHeaders } from './lib/headers.js'
|
|
8
|
+
import { startStatelessBridge } from './gateways/statelessBridge.js'
|
|
9
|
+
import { startStatefulBridge } from './gateways/statefulBridge.js'
|
|
10
|
+
import type { AuthConfig } from './types.js'
|
|
11
|
+
|
|
12
|
+
const argv = await yargs(hideBin(process.argv))
|
|
13
|
+
.scriptName('platf-bridge')
|
|
14
|
+
.usage('$0 — Stdio-to-Streamable HTTP bridge for MCP servers')
|
|
15
|
+
.option('stdio', {
|
|
16
|
+
type: 'string',
|
|
17
|
+
demandOption: true,
|
|
18
|
+
describe: 'Shell command that speaks MCP over stdio (stdin/stdout)',
|
|
19
|
+
})
|
|
20
|
+
.option('port', {
|
|
21
|
+
type: 'number',
|
|
22
|
+
default: 8000,
|
|
23
|
+
describe: 'HTTP port to listen on',
|
|
24
|
+
})
|
|
25
|
+
.option('path', {
|
|
26
|
+
type: 'string',
|
|
27
|
+
default: '/mcp',
|
|
28
|
+
describe: 'HTTP path for the Streamable HTTP endpoint',
|
|
29
|
+
})
|
|
30
|
+
.option('stateful', {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
default: false,
|
|
33
|
+
describe: 'Enable stateful mode (session-based, persistent child process per session)',
|
|
34
|
+
})
|
|
35
|
+
.option('sessionTimeout', {
|
|
36
|
+
type: 'number',
|
|
37
|
+
describe: 'Session inactivity timeout in milliseconds (stateful mode only)',
|
|
38
|
+
})
|
|
39
|
+
.option('protocolVersion', {
|
|
40
|
+
type: 'string',
|
|
41
|
+
default: '2025-03-26',
|
|
42
|
+
describe: 'MCP protocol version for auto-initialization (stateless mode)',
|
|
43
|
+
})
|
|
44
|
+
.option('logLevel', {
|
|
45
|
+
type: 'string',
|
|
46
|
+
choices: ['none', 'info', 'debug'] as const,
|
|
47
|
+
default: 'info',
|
|
48
|
+
describe: 'Log verbosity',
|
|
49
|
+
})
|
|
50
|
+
.option('cors', {
|
|
51
|
+
type: 'array',
|
|
52
|
+
describe: 'CORS origins to allow (omit for no CORS, pass * for all)',
|
|
53
|
+
})
|
|
54
|
+
.option('healthEndpoint', {
|
|
55
|
+
type: 'array',
|
|
56
|
+
string: true,
|
|
57
|
+
default: [] as string[],
|
|
58
|
+
describe: 'Path(s) that return 200 "ok" for health checks',
|
|
59
|
+
})
|
|
60
|
+
.option('header', {
|
|
61
|
+
type: 'array',
|
|
62
|
+
default: [] as (string | number)[],
|
|
63
|
+
describe: 'Additional response headers in "Key: Value" format',
|
|
64
|
+
})
|
|
65
|
+
.option('authIssuer', {
|
|
66
|
+
type: 'string',
|
|
67
|
+
describe: 'OAuth issuer URL (e.g. https://auth.platf.ai). Enables auth when set.',
|
|
68
|
+
})
|
|
69
|
+
.option('authClientId', {
|
|
70
|
+
type: 'string',
|
|
71
|
+
describe: 'OAuth client_id for this bridge instance (pre-registered with auth issuer)',
|
|
72
|
+
})
|
|
73
|
+
.strict()
|
|
74
|
+
.help()
|
|
75
|
+
.parse()
|
|
76
|
+
|
|
77
|
+
const logger = getLogger(argv.logLevel as LogLevel)
|
|
78
|
+
const corsOrigin = parseCorsOrigin(argv.cors as (string | number)[] | undefined)
|
|
79
|
+
const headers = parseHeaders(argv.header, logger)
|
|
80
|
+
|
|
81
|
+
// Build auth config (only when --authIssuer is provided)
|
|
82
|
+
let auth: AuthConfig | null = null
|
|
83
|
+
if (argv.authIssuer) {
|
|
84
|
+
if (!argv.authClientId) {
|
|
85
|
+
console.error('Error: --authClientId is required when --authIssuer is set')
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
auth = { issuer: argv.authIssuer.replace(/\/$/, ''), clientId: argv.authClientId }
|
|
89
|
+
logger.info(` Auth: enabled (issuer=${auth.issuer}, clientId=${auth.clientId})`)
|
|
90
|
+
} else {
|
|
91
|
+
logger.info(' Auth: disabled')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
logger.info('platf-bridge starting...')
|
|
95
|
+
logger.info(` Mode: ${argv.stateful ? 'stateful' : 'stateless'}`)
|
|
96
|
+
|
|
97
|
+
if (argv.stateful) {
|
|
98
|
+
await startStatefulBridge({
|
|
99
|
+
stdioCmd: argv.stdio,
|
|
100
|
+
port: argv.port,
|
|
101
|
+
path: argv.path,
|
|
102
|
+
logger,
|
|
103
|
+
corsOrigin,
|
|
104
|
+
healthEndpoints: argv.healthEndpoint,
|
|
105
|
+
headers,
|
|
106
|
+
sessionTimeout: argv.sessionTimeout ?? null,
|
|
107
|
+
auth,
|
|
108
|
+
})
|
|
109
|
+
} else {
|
|
110
|
+
await startStatelessBridge({
|
|
111
|
+
stdioCmd: argv.stdio,
|
|
112
|
+
port: argv.port,
|
|
113
|
+
path: argv.path,
|
|
114
|
+
logger,
|
|
115
|
+
corsOrigin,
|
|
116
|
+
healthEndpoints: argv.healthEndpoint,
|
|
117
|
+
headers,
|
|
118
|
+
protocolVersion: argv.protocolVersion,
|
|
119
|
+
auth,
|
|
120
|
+
})
|
|
121
|
+
}
|