@platf/bridge 0.0.15 → 0.0.17
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/dist/gateways/statefulBridge.d.ts +10 -7
- package/dist/gateways/statefulBridge.js +79 -139
- package/dist/gateways/statefulBridge.js.map +1 -1
- package/dist/gateways/statelessBridge.d.ts +10 -7
- package/dist/gateways/statelessBridge.js +85 -166
- package/dist/gateways/statelessBridge.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/childProcess.d.ts +37 -0
- package/dist/lib/childProcess.js +80 -0
- package/dist/lib/childProcess.js.map +1 -0
- package/dist/lib/config.d.ts +6 -0
- package/dist/lib/config.js +7 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/discoveryRoutes.d.ts +2 -0
- package/dist/lib/discoveryRoutes.js +9 -65
- package/dist/lib/discoveryRoutes.js.map +1 -1
- package/dist/lib/express.d.ts +33 -0
- package/dist/lib/express.js +65 -0
- package/dist/lib/express.js.map +1 -0
- package/dist/lib/getLogger.d.ts +2 -2
- package/dist/lib/getLogger.js.map +1 -1
- package/dist/lib/mcpMessages.d.ts +10 -0
- package/dist/lib/mcpMessages.js +35 -0
- package/dist/lib/mcpMessages.js.map +1 -0
- package/dist/lib/oauthProxy.d.ts +14 -0
- package/dist/lib/oauthProxy.js +80 -0
- package/dist/lib/oauthProxy.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
- package/src/gateways/statefulBridge.ts +87 -167
- package/src/gateways/statelessBridge.ts +100 -218
- package/src/index.ts +2 -2
- package/src/lib/childProcess.ts +120 -0
- package/src/lib/config.ts +9 -0
- package/src/lib/discoveryRoutes.ts +9 -68
- package/src/lib/express.ts +82 -0
- package/src/lib/getLogger.ts +4 -3
- package/src/lib/mcpMessages.ts +42 -0
- package/src/lib/oauthProxy.ts +86 -0
- package/src/types.ts +4 -0
|
@@ -1,18 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateful stdio-to-Streamable HTTP bridge.
|
|
3
|
+
*
|
|
4
|
+
* Maintains session state via `Mcp-Session-Id` header. Each session
|
|
5
|
+
* spawns one child process; subsequent requests for the same session
|
|
6
|
+
* reuse the existing transport and process. Sessions are cleaned up
|
|
7
|
+
* after an optional inactivity timeout.
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
import express from 'express'
|
|
2
|
-
import cors, { type CorsOptions } from 'cors'
|
|
3
|
-
import { spawn } from 'child_process'
|
|
4
11
|
import { randomUUID } from 'node:crypto'
|
|
5
12
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
6
13
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
7
14
|
import { type JSONRPCMessage, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
|
|
15
|
+
import type { CorsOptions } from 'cors'
|
|
8
16
|
import type { AuthConfig, Logger } from '../types.js'
|
|
17
|
+
import { createApp } from '../lib/express.js'
|
|
9
18
|
import { onSignals } from '../lib/onSignals.js'
|
|
10
|
-
import {
|
|
19
|
+
import { spawnManagedChild, type ManagedChildProcess } from '../lib/childProcess.js'
|
|
11
20
|
import { SessionAccessCounter } from '../lib/sessionAccessCounter.js'
|
|
12
|
-
import {
|
|
13
|
-
import { createDiscoveryRouter } from '../lib/discoveryRoutes.js'
|
|
14
|
-
|
|
15
|
-
const VERSION = '1.0.0'
|
|
21
|
+
import { VERSION, SERVER_NAME } from '../lib/config.js'
|
|
16
22
|
|
|
17
23
|
export interface StatefulBridgeArgs {
|
|
18
24
|
stdioCmd: string
|
|
@@ -26,219 +32,133 @@ export interface StatefulBridgeArgs {
|
|
|
26
32
|
auth: AuthConfig | null
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
interface SessionState {
|
|
36
|
+
transport: StreamableHTTPServerTransport
|
|
37
|
+
child: ManagedChildProcess
|
|
38
|
+
}
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
|
-
*
|
|
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.
|
|
41
|
+
* Start the stateful bridge server.
|
|
39
42
|
*/
|
|
40
43
|
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
|
|
44
|
+
const { stdioCmd, port, path, logger, corsOrigin, healthEndpoints, headers, sessionTimeout, auth } = args
|
|
52
45
|
|
|
53
|
-
logger.info(`[stateful] Starting
|
|
54
|
-
logger.info(` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`)
|
|
46
|
+
logger.info(`[stateful] Starting ${SERVER_NAME}`)
|
|
55
47
|
logger.info(` - port: ${port}`)
|
|
56
48
|
logger.info(` - stdio: ${stdioCmd}`)
|
|
57
49
|
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
50
|
logger.info(` - Session timeout: ${sessionTimeout ? `${sessionTimeout}ms` : 'disabled'}`)
|
|
51
|
+
logger.info(` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`)
|
|
65
52
|
|
|
66
53
|
onSignals({ logger })
|
|
67
54
|
|
|
68
|
-
const app =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (const ep of healthEndpoints) {
|
|
77
|
-
app.get(ep, (_req, res) => {
|
|
78
|
-
setResponseHeaders(res, headers)
|
|
79
|
-
res.send('ok')
|
|
80
|
-
})
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// --- OAuth discovery & auth middleware (when auth is enabled) ---
|
|
84
|
-
if (auth) {
|
|
85
|
-
app.use(createDiscoveryRouter(auth, logger))
|
|
86
|
-
app.use(path, createAuthMiddleware(auth, logger))
|
|
87
|
-
logger.info(` - Auth: enabled (issuer=${auth.issuer})`)
|
|
88
|
-
}
|
|
55
|
+
const app = createApp({
|
|
56
|
+
logger,
|
|
57
|
+
corsOrigin,
|
|
58
|
+
healthEndpoints,
|
|
59
|
+
headers,
|
|
60
|
+
auth,
|
|
61
|
+
mcpPath: path,
|
|
62
|
+
})
|
|
89
63
|
|
|
90
64
|
// Session state
|
|
91
|
-
const
|
|
65
|
+
const sessions: Record<string, SessionState> = {}
|
|
92
66
|
|
|
93
67
|
const sessionCounter = sessionTimeout
|
|
94
68
|
? new SessionAccessCounter(
|
|
95
69
|
sessionTimeout,
|
|
96
70
|
(sessionId) => {
|
|
97
|
-
logger.info(`Session ${sessionId} timed out, cleaning up`)
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
71
|
+
logger.info(`[stateful] Session ${sessionId} timed out, cleaning up`)
|
|
72
|
+
const session = sessions[sessionId]
|
|
73
|
+
if (session) {
|
|
74
|
+
session.transport.close()
|
|
75
|
+
session.child.kill()
|
|
76
|
+
}
|
|
77
|
+
delete sessions[sessionId]
|
|
101
78
|
},
|
|
102
79
|
logger,
|
|
103
80
|
)
|
|
104
81
|
: null
|
|
105
82
|
|
|
106
|
-
//
|
|
83
|
+
// POST handler
|
|
107
84
|
app.post(path, async (req, res) => {
|
|
108
85
|
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
109
|
-
let
|
|
86
|
+
let session: SessionState
|
|
110
87
|
|
|
111
|
-
if (sessionId &&
|
|
88
|
+
if (sessionId && sessions[sessionId]) {
|
|
112
89
|
// Reuse existing session
|
|
113
|
-
|
|
90
|
+
session = sessions[sessionId]
|
|
114
91
|
sessionCounter?.inc(sessionId, 'POST request for existing session')
|
|
115
92
|
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
116
93
|
// New session — spawn child process
|
|
117
|
-
const server = new Server(
|
|
118
|
-
{ name: 'platf-bridge', version: VERSION },
|
|
119
|
-
{ capabilities: {} },
|
|
120
|
-
)
|
|
94
|
+
const server = new Server({ name: SERVER_NAME, version: VERSION }, { capabilities: {} })
|
|
121
95
|
|
|
122
|
-
transport = new StreamableHTTPServerTransport({
|
|
96
|
+
const transport = new StreamableHTTPServerTransport({
|
|
123
97
|
sessionIdGenerator: () => randomUUID(),
|
|
124
98
|
onsessioninitialized: (newSessionId) => {
|
|
125
|
-
|
|
99
|
+
sessions[newSessionId] = session
|
|
126
100
|
sessionCounter?.inc(newSessionId, 'session initialization')
|
|
127
101
|
},
|
|
128
102
|
})
|
|
129
103
|
|
|
130
104
|
await server.connect(transport)
|
|
131
|
-
logger.info('[
|
|
132
|
-
|
|
133
|
-
const child = spawn(stdioCmd, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
134
|
-
logger.info(`[debug] Child spawned, pid=${child.pid}`)
|
|
105
|
+
logger.info('[stateful] Server connected to transport')
|
|
135
106
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
child.on('error', (err) => {
|
|
144
|
-
logger.error(`[debug] Child error event: ${err.message}`)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
child.on('exit', (code, signal) => {
|
|
148
|
-
logger.error(`Child exited: code=${code}, signal=${signal}`)
|
|
149
|
-
|
|
150
|
-
// Send JSON-RPC error responses for all pending requests
|
|
151
|
-
for (const id of pendingRequestIds) {
|
|
152
|
-
const detail = stderrOutput.trim().slice(0, 1000)
|
|
153
|
-
const message = detail
|
|
154
|
-
? `Child process exited (code=${code}): ${detail}`
|
|
155
|
-
: `Child process exited unexpectedly (code=${code}, signal=${signal})`
|
|
156
|
-
transport.send({
|
|
157
|
-
jsonrpc: '2.0',
|
|
158
|
-
error: { code: -32603, message },
|
|
159
|
-
id,
|
|
160
|
-
} as JSONRPCMessage).catch(e => {
|
|
161
|
-
logger.error(`Failed to send error response for request ${id}`, e)
|
|
107
|
+
const managedChild = spawnManagedChild(stdioCmd, logger, {
|
|
108
|
+
onMessage: (msg) => {
|
|
109
|
+
logger.info(`[stateful] Child → HTTP: ${JSON.stringify(msg).slice(0, 500)}`)
|
|
110
|
+
transport.send(msg).catch((e) => {
|
|
111
|
+
logger.error('[stateful] Failed to send to HTTP transport', e)
|
|
162
112
|
})
|
|
163
|
-
}
|
|
164
|
-
pendingRequestIds.clear()
|
|
165
|
-
|
|
166
|
-
transport.close()
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
let buffer = ''
|
|
170
|
-
child.stdout.on('data', (chunk: Buffer) => {
|
|
171
|
-
const chunkStr = chunk.toString('utf8')
|
|
172
|
-
logger.info(`[debug] stdout.on('data') received ${chunk.length} bytes: ${chunkStr.slice(0, 200)}...`)
|
|
173
|
-
buffer += chunkStr
|
|
174
|
-
const lines = buffer.split(/\r?\n/)
|
|
175
|
-
buffer = lines.pop() ?? ''
|
|
176
|
-
logger.info(`[debug] Split into ${lines.length} lines, remaining buffer: ${buffer.length} chars`)
|
|
113
|
+
},
|
|
177
114
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
logger.error(`Child non-JSON: ${line}`)
|
|
115
|
+
onExit: (code, signal, stderrOutput) => {
|
|
116
|
+
logger.error(`[stateful] Child exited: code=${code}, signal=${signal}`)
|
|
117
|
+
|
|
118
|
+
// Send error responses for all pending requests
|
|
119
|
+
for (const id of managedChild.getPendingRequests()) {
|
|
120
|
+
const detail = stderrOutput.trim().slice(0, 1000)
|
|
121
|
+
const message = detail
|
|
122
|
+
? `Child process exited (code=${code}): ${detail}`
|
|
123
|
+
: `Child process exited unexpectedly (code=${code}, signal=${signal})`
|
|
124
|
+
transport.send({
|
|
125
|
+
jsonrpc: '2.0',
|
|
126
|
+
error: { code: -32603, message },
|
|
127
|
+
id,
|
|
128
|
+
} as JSONRPCMessage).catch(() => {})
|
|
193
129
|
}
|
|
194
|
-
}
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
child.stderr.on('data', (chunk: Buffer) => {
|
|
198
|
-
const text = chunk.toString('utf8')
|
|
199
|
-
stderrOutput += text
|
|
200
|
-
logger.error(`Child stderr: ${text}`)
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
child.stdout.on('close', () => {
|
|
204
|
-
logger.info('[debug] child.stdout closed')
|
|
205
|
-
})
|
|
206
130
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
child.stdin.on('error', (err) => {
|
|
212
|
-
logger.error(`[debug] child.stdin error: ${err.message}`)
|
|
131
|
+
transport.close()
|
|
132
|
+
},
|
|
213
133
|
})
|
|
214
134
|
|
|
215
135
|
transport.onmessage = (msg: JSONRPCMessage) => {
|
|
216
|
-
logger.info(`HTTP → Child: ${JSON.stringify(msg)}`)
|
|
136
|
+
logger.info(`[stateful] HTTP → Child: ${JSON.stringify(msg)}`)
|
|
217
137
|
if ('id' in msg && msg.id !== undefined) {
|
|
218
|
-
|
|
138
|
+
managedChild.trackRequest(msg.id as string | number)
|
|
219
139
|
}
|
|
220
|
-
|
|
221
|
-
const written = child.stdin.write(payload)
|
|
222
|
-
logger.info(`[debug] stdin.write() returned ${written}, payload length=${payload.length}`)
|
|
140
|
+
managedChild.send(msg)
|
|
223
141
|
}
|
|
224
142
|
|
|
225
143
|
transport.onclose = () => {
|
|
226
|
-
logger.info(`HTTP connection closed (session ${sessionId})`)
|
|
144
|
+
logger.info(`[stateful] HTTP connection closed (session ${transport.sessionId})`)
|
|
227
145
|
if (transport.sessionId) {
|
|
228
146
|
sessionCounter?.clear(transport.sessionId, false, 'transport closed')
|
|
229
|
-
delete
|
|
147
|
+
delete sessions[transport.sessionId]
|
|
230
148
|
}
|
|
231
|
-
|
|
149
|
+
managedChild.kill()
|
|
232
150
|
}
|
|
233
151
|
|
|
234
152
|
transport.onerror = (err) => {
|
|
235
|
-
logger.error(`HTTP transport error (session ${sessionId}):`, err)
|
|
153
|
+
logger.error(`[stateful] HTTP transport error (session ${transport.sessionId}):`, err)
|
|
236
154
|
if (transport.sessionId) {
|
|
237
155
|
sessionCounter?.clear(transport.sessionId, false, 'transport error')
|
|
238
|
-
delete
|
|
156
|
+
delete sessions[transport.sessionId]
|
|
239
157
|
}
|
|
240
|
-
|
|
158
|
+
managedChild.kill()
|
|
241
159
|
}
|
|
160
|
+
|
|
161
|
+
session = { transport, child: managedChild }
|
|
242
162
|
} else {
|
|
243
163
|
res.status(400).json({
|
|
244
164
|
jsonrpc: '2.0',
|
|
@@ -251,22 +171,22 @@ export async function startStatefulBridge(args: StatefulBridgeArgs) {
|
|
|
251
171
|
// Track response lifecycle for session cleanup
|
|
252
172
|
let responseEnded = false
|
|
253
173
|
const handleResponseEnd = (event: string) => {
|
|
254
|
-
if (!responseEnded && transport.sessionId) {
|
|
174
|
+
if (!responseEnded && session.transport.sessionId) {
|
|
255
175
|
responseEnded = true
|
|
256
|
-
logger.info(`Response ${event}`, transport.sessionId)
|
|
257
|
-
sessionCounter?.dec(transport.sessionId, `POST response ${event}`)
|
|
176
|
+
logger.info(`[stateful] Response ${event}`, session.transport.sessionId)
|
|
177
|
+
sessionCounter?.dec(session.transport.sessionId, `POST response ${event}`)
|
|
258
178
|
}
|
|
259
179
|
}
|
|
260
180
|
res.on('finish', () => handleResponseEnd('finished'))
|
|
261
181
|
res.on('close', () => handleResponseEnd('closed'))
|
|
262
182
|
|
|
263
|
-
await transport.handleRequest(req, res, req.body)
|
|
183
|
+
await session.transport.handleRequest(req, res, req.body)
|
|
264
184
|
})
|
|
265
185
|
|
|
266
|
-
//
|
|
186
|
+
// GET/DELETE handler (session-bound)
|
|
267
187
|
const handleSessionRequest = async (req: express.Request, res: express.Response) => {
|
|
268
188
|
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
269
|
-
if (!sessionId || !
|
|
189
|
+
if (!sessionId || !sessions[sessionId]) {
|
|
270
190
|
res.status(400).send('Invalid or missing session ID')
|
|
271
191
|
return
|
|
272
192
|
}
|
|
@@ -277,15 +197,15 @@ export async function startStatefulBridge(args: StatefulBridgeArgs) {
|
|
|
277
197
|
const handleResponseEnd = (event: string) => {
|
|
278
198
|
if (!responseEnded) {
|
|
279
199
|
responseEnded = true
|
|
280
|
-
logger.info(`Response ${event}`, sessionId)
|
|
200
|
+
logger.info(`[stateful] Response ${event}`, sessionId)
|
|
281
201
|
sessionCounter?.dec(sessionId, `${req.method} response ${event}`)
|
|
282
202
|
}
|
|
283
203
|
}
|
|
284
204
|
res.on('finish', () => handleResponseEnd('finished'))
|
|
285
205
|
res.on('close', () => handleResponseEnd('closed'))
|
|
286
206
|
|
|
287
|
-
const
|
|
288
|
-
await transport.handleRequest(req, res)
|
|
207
|
+
const session = sessions[sessionId]
|
|
208
|
+
await session.transport.handleRequest(req, res)
|
|
289
209
|
}
|
|
290
210
|
|
|
291
211
|
app.get(path, handleSessionRequest)
|