@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.
Files changed (40) hide show
  1. package/dist/gateways/statefulBridge.d.ts +10 -7
  2. package/dist/gateways/statefulBridge.js +79 -139
  3. package/dist/gateways/statefulBridge.js.map +1 -1
  4. package/dist/gateways/statelessBridge.d.ts +10 -7
  5. package/dist/gateways/statelessBridge.js +85 -166
  6. package/dist/gateways/statelessBridge.js.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/childProcess.d.ts +37 -0
  9. package/dist/lib/childProcess.js +80 -0
  10. package/dist/lib/childProcess.js.map +1 -0
  11. package/dist/lib/config.d.ts +6 -0
  12. package/dist/lib/config.js +7 -0
  13. package/dist/lib/config.js.map +1 -0
  14. package/dist/lib/discoveryRoutes.d.ts +2 -0
  15. package/dist/lib/discoveryRoutes.js +9 -65
  16. package/dist/lib/discoveryRoutes.js.map +1 -1
  17. package/dist/lib/express.d.ts +33 -0
  18. package/dist/lib/express.js +65 -0
  19. package/dist/lib/express.js.map +1 -0
  20. package/dist/lib/getLogger.d.ts +2 -2
  21. package/dist/lib/getLogger.js.map +1 -1
  22. package/dist/lib/mcpMessages.d.ts +10 -0
  23. package/dist/lib/mcpMessages.js +35 -0
  24. package/dist/lib/mcpMessages.js.map +1 -0
  25. package/dist/lib/oauthProxy.d.ts +14 -0
  26. package/dist/lib/oauthProxy.js +80 -0
  27. package/dist/lib/oauthProxy.js.map +1 -0
  28. package/dist/types.d.ts +3 -0
  29. package/package.json +1 -1
  30. package/src/gateways/statefulBridge.ts +87 -167
  31. package/src/gateways/statelessBridge.ts +100 -218
  32. package/src/index.ts +2 -2
  33. package/src/lib/childProcess.ts +120 -0
  34. package/src/lib/config.ts +9 -0
  35. package/src/lib/discoveryRoutes.ts +9 -68
  36. package/src/lib/express.ts +82 -0
  37. package/src/lib/getLogger.ts +4 -3
  38. package/src/lib/mcpMessages.ts +42 -0
  39. package/src/lib/oauthProxy.ts +86 -0
  40. 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 { serializeCorsOrigin } from '../lib/cors.js'
19
+ import { spawnManagedChild, type ManagedChildProcess } from '../lib/childProcess.js'
11
20
  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'
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
- const setResponseHeaders = (res: express.Response, headers: Record<string, string>) =>
30
- Object.entries(headers).forEach(([key, value]) => res.setHeader(key, value))
35
+ interface SessionState {
36
+ transport: StreamableHTTPServerTransport
37
+ child: ManagedChildProcess
38
+ }
31
39
 
32
40
  /**
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.
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 platf-bridge`)
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 = express()
69
- app.set('trust proxy', true)
70
- app.use(express.json())
71
-
72
- if (corsOrigin) {
73
- app.use(cors({ origin: corsOrigin, exposedHeaders: ['Mcp-Session-Id'] }))
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 transports: Record<string, StreamableHTTPServerTransport> = {}
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 transport = transports[sessionId]
99
- if (transport) transport.close()
100
- delete transports[sessionId]
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
- // --- POST handler ---
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 transport: StreamableHTTPServerTransport
86
+ let session: SessionState
110
87
 
111
- if (sessionId && transports[sessionId]) {
88
+ if (sessionId && sessions[sessionId]) {
112
89
  // Reuse existing session
113
- transport = transports[sessionId]
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
- transports[newSessionId] = transport
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('[debug] Server connected to transport')
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 pendingRequestIds = new Set<string | number>()
137
- let stderrOutput = ''
138
-
139
- child.on('spawn', () => {
140
- logger.info(`[debug] Child spawn event fired, pid=${child.pid}`)
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
- for (const line of lines) {
179
- if (!line.trim()) continue
180
- try {
181
- const jsonMsg = JSON.parse(line)
182
- if ('id' in jsonMsg && jsonMsg.id !== undefined) {
183
- pendingRequestIds.delete(jsonMsg.id)
184
- }
185
- logger.info('Child HTTP:', line.slice(0, 500))
186
- transport.send(jsonMsg).then(() => {
187
- logger.info('[debug] transport.send() succeeded')
188
- }).catch(e => {
189
- logger.error('Failed to send to HTTP transport', e)
190
- })
191
- } catch {
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
- child.stdout.on('end', () => {
208
- logger.info('[debug] child.stdout ended')
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
- pendingRequestIds.add(msg.id as string | number)
138
+ managedChild.trackRequest(msg.id as string | number)
219
139
  }
220
- const payload = JSON.stringify(msg) + '\n'
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 transports[transport.sessionId]
147
+ delete sessions[transport.sessionId]
230
148
  }
231
- child.kill()
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 transports[transport.sessionId]
156
+ delete sessions[transport.sessionId]
239
157
  }
240
- child.kill()
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
- // --- GET / DELETE handler (session-bound) ---
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 || !transports[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 transport = transports[sessionId]
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)