@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/gateways/statefulBridge.d.ts +22 -0
  4. package/dist/gateways/statefulBridge.js +211 -0
  5. package/dist/gateways/statefulBridge.js.map +1 -0
  6. package/dist/gateways/statelessBridge.d.ts +22 -0
  7. package/dist/gateways/statelessBridge.js +234 -0
  8. package/dist/gateways/statelessBridge.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +117 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/lib/authMiddleware.d.ts +12 -0
  13. package/dist/lib/authMiddleware.js +41 -0
  14. package/dist/lib/authMiddleware.js.map +1 -0
  15. package/dist/lib/cors.d.ts +3 -0
  16. package/dist/lib/cors.js +29 -0
  17. package/dist/lib/cors.js.map +1 -0
  18. package/dist/lib/discoveryRoutes.d.ts +14 -0
  19. package/dist/lib/discoveryRoutes.js +86 -0
  20. package/dist/lib/discoveryRoutes.js.map +1 -0
  21. package/dist/lib/getLogger.d.ts +3 -0
  22. package/dist/lib/getLogger.js +34 -0
  23. package/dist/lib/getLogger.js.map +1 -0
  24. package/dist/lib/headers.d.ts +2 -0
  25. package/dist/lib/headers.js +19 -0
  26. package/dist/lib/headers.js.map +1 -0
  27. package/dist/lib/onSignals.d.ts +6 -0
  28. package/dist/lib/onSignals.js +16 -0
  29. package/dist/lib/onSignals.js.map +1 -0
  30. package/dist/lib/sessionAccessCounter.d.ts +11 -0
  31. package/dist/lib/sessionAccessCounter.js +72 -0
  32. package/dist/lib/sessionAccessCounter.js.map +1 -0
  33. package/dist/types.d.ts +11 -0
  34. package/dist/types.js +2 -0
  35. package/dist/types.js.map +1 -0
  36. package/package.json +41 -0
  37. package/src/gateways/statefulBridge.ts +271 -0
  38. package/src/gateways/statelessBridge.ts +310 -0
  39. package/src/index.ts +121 -0
  40. package/src/lib/authMiddleware.ts +58 -0
  41. package/src/lib/cors.ts +30 -0
  42. package/src/lib/discoveryRoutes.ts +95 -0
  43. package/src/lib/getLogger.ts +49 -0
  44. package/src/lib/headers.ts +26 -0
  45. package/src/lib/onSignals.ts +24 -0
  46. package/src/lib/sessionAccessCounter.ts +98 -0
  47. 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
+ }