@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,19 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Stateless stdio-to-Streamable HTTP bridge.
|
|
3
|
+
*
|
|
4
|
+
* For every incoming POST request, spawns a fresh child process,
|
|
5
|
+
* auto-initializes it if the request is not an initialize request,
|
|
6
|
+
* and proxies JSON-RPC messages between the HTTP transport and the
|
|
7
|
+
* child's stdin/stdout.
|
|
8
|
+
*/
|
|
9
|
+
|
|
4
10
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
5
11
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
isInitializeRequest,
|
|
9
|
-
} from '@modelcontextprotocol/sdk/types.js'
|
|
12
|
+
import { type JSONRPCMessage, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
|
|
13
|
+
import type { CorsOptions } from 'cors'
|
|
10
14
|
import type { AuthConfig, Logger } from '../types.js'
|
|
15
|
+
import { createApp, setResponseHeaders } from '../lib/express.js'
|
|
11
16
|
import { onSignals } from '../lib/onSignals.js'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
const VERSION = '1.0.0'
|
|
17
|
+
import { spawnManagedChild } from '../lib/childProcess.js'
|
|
18
|
+
import { createInitializeRequest, createInitializedNotification, generateAutoInitId } from '../lib/mcpMessages.js'
|
|
19
|
+
import { VERSION, SERVER_NAME } from '../lib/config.js'
|
|
17
20
|
|
|
18
21
|
export interface StatelessBridgeArgs {
|
|
19
22
|
stdioCmd: string
|
|
@@ -27,225 +30,115 @@ export interface StatelessBridgeArgs {
|
|
|
27
30
|
auth: AuthConfig | null
|
|
28
31
|
}
|
|
29
32
|
|
|
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
33
|
/**
|
|
60
|
-
*
|
|
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.
|
|
34
|
+
* Start the stateless bridge server.
|
|
66
35
|
*/
|
|
67
36
|
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
|
|
37
|
+
const { stdioCmd, port, path, logger, corsOrigin, healthEndpoints, headers, protocolVersion, auth } = args
|
|
79
38
|
|
|
80
|
-
logger.info(`[stateless] Starting
|
|
81
|
-
logger.info(` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`)
|
|
39
|
+
logger.info(`[stateless] Starting ${SERVER_NAME}`)
|
|
82
40
|
logger.info(` - port: ${port}`)
|
|
83
41
|
logger.info(` - stdio: ${stdioCmd}`)
|
|
84
42
|
logger.info(` - path: ${path}`)
|
|
85
43
|
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
|
-
)
|
|
44
|
+
logger.info(` - Headers: ${Object.keys(headers).length ? JSON.stringify(headers) : '(none)'}`)
|
|
92
45
|
|
|
93
46
|
onSignals({ logger })
|
|
94
47
|
|
|
95
|
-
const app =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
for (const ep of healthEndpoints) {
|
|
104
|
-
app.get(ep, (_req, res) => {
|
|
105
|
-
setResponseHeaders(res, headers)
|
|
106
|
-
res.send('ok')
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// --- OAuth discovery & auth middleware (when auth is enabled) ---
|
|
111
|
-
if (auth) {
|
|
112
|
-
app.use(createDiscoveryRouter(auth, logger))
|
|
113
|
-
app.use(path, createAuthMiddleware(auth, logger))
|
|
114
|
-
logger.info(` - Auth: enabled (issuer=${auth.issuer})`)
|
|
115
|
-
}
|
|
48
|
+
const app = createApp({
|
|
49
|
+
logger,
|
|
50
|
+
corsOrigin,
|
|
51
|
+
healthEndpoints,
|
|
52
|
+
headers,
|
|
53
|
+
auth,
|
|
54
|
+
mcpPath: path,
|
|
55
|
+
})
|
|
116
56
|
|
|
57
|
+
// POST handler — spawn child, proxy messages
|
|
117
58
|
app.post(path, async (req, res) => {
|
|
118
59
|
try {
|
|
119
|
-
const server = new Server(
|
|
120
|
-
|
|
121
|
-
{ capabilities: {} },
|
|
122
|
-
)
|
|
123
|
-
const transport = new StreamableHTTPServerTransport({
|
|
124
|
-
sessionIdGenerator: undefined,
|
|
125
|
-
})
|
|
126
|
-
|
|
60
|
+
const server = new Server({ name: SERVER_NAME, version: VERSION }, { capabilities: {} })
|
|
61
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
127
62
|
await server.connect(transport)
|
|
128
|
-
const child = spawn(stdioCmd, { shell: true })
|
|
129
|
-
|
|
130
|
-
const pendingRequestIds = new Set<string | number>()
|
|
131
|
-
let stderrOutput = ''
|
|
132
|
-
|
|
133
|
-
child.on('exit', (code, signal) => {
|
|
134
|
-
logger.error(`Child exited: code=${code}, signal=${signal}`)
|
|
135
|
-
|
|
136
|
-
// Include queued original message's ID if it was never sent to the child
|
|
137
|
-
if (pendingOriginalMessage && 'id' in pendingOriginalMessage && pendingOriginalMessage.id !== undefined) {
|
|
138
|
-
pendingRequestIds.add(pendingOriginalMessage.id as string | number)
|
|
139
|
-
pendingOriginalMessage = null
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Remove auto-init ID — the client doesn't expect a response for it
|
|
143
|
-
if (isAutoInitializing && initializeRequestId !== null) {
|
|
144
|
-
pendingRequestIds.delete(initializeRequestId)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Send JSON-RPC error responses for all pending client requests
|
|
148
|
-
for (const id of pendingRequestIds) {
|
|
149
|
-
const detail = stderrOutput.trim().slice(0, 1000)
|
|
150
|
-
const message = detail
|
|
151
|
-
? `Child process exited (code=${code}): ${detail}`
|
|
152
|
-
: `Child process exited unexpectedly (code=${code}, signal=${signal})`
|
|
153
|
-
try {
|
|
154
|
-
transport.send({
|
|
155
|
-
jsonrpc: '2.0',
|
|
156
|
-
error: { code: -32603, message },
|
|
157
|
-
id,
|
|
158
|
-
} as JSONRPCMessage)
|
|
159
|
-
} catch (e) {
|
|
160
|
-
logger.error(`Failed to send error response for request ${id}`, e)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
pendingRequestIds.clear()
|
|
164
|
-
|
|
165
|
-
transport.close()
|
|
166
|
-
})
|
|
167
63
|
|
|
168
|
-
//
|
|
64
|
+
// Auto-initialization state
|
|
169
65
|
let isInitialized = false
|
|
170
66
|
let initializeRequestId: string | number | null = null
|
|
171
67
|
let isAutoInitializing = false
|
|
172
68
|
let pendingOriginalMessage: JSONRPCMessage | null = null
|
|
173
69
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
for (const line of lines) {
|
|
181
|
-
if (!line.trim()) continue
|
|
182
|
-
try {
|
|
183
|
-
const jsonMsg = JSON.parse(line)
|
|
184
|
-
if ('id' in jsonMsg && jsonMsg.id !== undefined) {
|
|
185
|
-
pendingRequestIds.delete(jsonMsg.id)
|
|
186
|
-
}
|
|
187
|
-
logger.info('Child → HTTP:', line)
|
|
188
|
-
|
|
189
|
-
// Handle initialize response (auto or client-initiated)
|
|
190
|
-
if (initializeRequestId && jsonMsg.id === initializeRequestId) {
|
|
191
|
-
logger.info('Initialize response received')
|
|
192
|
-
isInitialized = true
|
|
193
|
-
|
|
194
|
-
if (isAutoInitializing) {
|
|
195
|
-
// Send initialized notification then the queued original message
|
|
196
|
-
const notification = createInitializedNotification()
|
|
197
|
-
logger.info(`HTTP → Child (initialized): ${JSON.stringify(notification)}`)
|
|
198
|
-
child.stdin.write(JSON.stringify(notification) + '\n')
|
|
70
|
+
const managedChild = spawnManagedChild(stdioCmd, logger, {
|
|
71
|
+
onMessage: (msg) => {
|
|
72
|
+
// Handle initialize response (auto or client-initiated)
|
|
73
|
+
if (initializeRequestId && 'id' in msg && msg.id === initializeRequestId) {
|
|
74
|
+
logger.info('[stateless] Initialize response received')
|
|
75
|
+
isInitialized = true
|
|
199
76
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
pendingOriginalMessage = null
|
|
204
|
-
}
|
|
77
|
+
if (isAutoInitializing) {
|
|
78
|
+
// Send initialized notification then the queued original message
|
|
79
|
+
managedChild.send(createInitializedNotification())
|
|
205
80
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
81
|
+
if (pendingOriginalMessage) {
|
|
82
|
+
managedChild.send(pendingOriginalMessage)
|
|
83
|
+
pendingOriginalMessage = null
|
|
209
84
|
}
|
|
210
85
|
|
|
86
|
+
isAutoInitializing = false
|
|
211
87
|
initializeRequestId = null
|
|
88
|
+
return // don't forward auto-init response to client
|
|
212
89
|
}
|
|
213
90
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
91
|
+
initializeRequestId = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
transport.send(msg).catch((e) => {
|
|
95
|
+
logger.error('[stateless] Failed to send to HTTP transport', e)
|
|
96
|
+
})
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
onExit: (code, signal, stderrOutput) => {
|
|
100
|
+
// Include queued original message's ID if never sent
|
|
101
|
+
if (pendingOriginalMessage && 'id' in pendingOriginalMessage && pendingOriginalMessage.id !== undefined) {
|
|
102
|
+
managedChild.trackRequest(pendingOriginalMessage.id as string | number)
|
|
103
|
+
pendingOriginalMessage = null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Remove auto-init ID — client doesn't expect a response for it
|
|
107
|
+
if (isAutoInitializing && initializeRequestId !== null) {
|
|
108
|
+
managedChild.completeRequest(initializeRequestId)
|
|
221
109
|
}
|
|
222
|
-
}
|
|
223
|
-
})
|
|
224
110
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
111
|
+
// Send error responses for all pending requests
|
|
112
|
+
for (const id of managedChild.getPendingRequests()) {
|
|
113
|
+
const detail = stderrOutput.trim().slice(0, 1000)
|
|
114
|
+
const message = detail
|
|
115
|
+
? `Child process exited (code=${code}): ${detail}`
|
|
116
|
+
: `Child process exited unexpectedly (code=${code}, signal=${signal})`
|
|
117
|
+
transport.send({
|
|
118
|
+
jsonrpc: '2.0',
|
|
119
|
+
error: { code: -32603, message },
|
|
120
|
+
id,
|
|
121
|
+
} as JSONRPCMessage).catch(() => {})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
transport.close()
|
|
125
|
+
},
|
|
229
126
|
})
|
|
230
127
|
|
|
231
128
|
transport.onmessage = (msg: JSONRPCMessage) => {
|
|
232
|
-
logger.info(`HTTP → Child: ${JSON.stringify(msg)}`)
|
|
233
|
-
|
|
234
129
|
// Track client request IDs for error reporting on child exit
|
|
235
130
|
if ('id' in msg && msg.id !== undefined) {
|
|
236
|
-
|
|
131
|
+
managedChild.trackRequest(msg.id as string | number)
|
|
237
132
|
}
|
|
238
133
|
|
|
239
|
-
// Auto-initialize if
|
|
134
|
+
// Auto-initialize if first message is not an initialize request
|
|
240
135
|
if (!isInitialized && !isInitializeRequest(msg)) {
|
|
241
136
|
pendingOriginalMessage = msg
|
|
242
|
-
initializeRequestId =
|
|
137
|
+
initializeRequestId = generateAutoInitId()
|
|
243
138
|
isAutoInitializing = true
|
|
244
139
|
|
|
245
|
-
logger.info('Non-initialize message detected, sending auto-initialize first')
|
|
246
|
-
|
|
247
|
-
logger.info(`HTTP → Child (auto-init): ${JSON.stringify(initReq)}`)
|
|
248
|
-
child.stdin.write(JSON.stringify(initReq) + '\n')
|
|
140
|
+
logger.info('[stateless] Non-initialize message detected, sending auto-initialize first')
|
|
141
|
+
managedChild.send(createInitializeRequest(initializeRequestId, protocolVersion))
|
|
249
142
|
return
|
|
250
143
|
}
|
|
251
144
|
|
|
@@ -253,25 +146,25 @@ export async function startStatelessBridge(args: StatelessBridgeArgs) {
|
|
|
253
146
|
if (isInitializeRequest(msg) && 'id' in msg && msg.id !== undefined) {
|
|
254
147
|
initializeRequestId = msg.id
|
|
255
148
|
isAutoInitializing = false
|
|
256
|
-
logger.info(`Tracking initialize request ID: ${msg.id}`)
|
|
149
|
+
logger.info(`[stateless] Tracking initialize request ID: ${msg.id}`)
|
|
257
150
|
}
|
|
258
151
|
|
|
259
|
-
|
|
152
|
+
managedChild.send(msg)
|
|
260
153
|
}
|
|
261
154
|
|
|
262
155
|
transport.onclose = () => {
|
|
263
|
-
logger.info('HTTP connection closed')
|
|
264
|
-
|
|
156
|
+
logger.info('[stateless] HTTP connection closed')
|
|
157
|
+
managedChild.kill()
|
|
265
158
|
}
|
|
266
159
|
|
|
267
160
|
transport.onerror = (err) => {
|
|
268
|
-
logger.error('HTTP transport error:', err)
|
|
269
|
-
|
|
161
|
+
logger.error('[stateless] HTTP transport error:', err)
|
|
162
|
+
managedChild.kill()
|
|
270
163
|
}
|
|
271
164
|
|
|
272
165
|
await transport.handleRequest(req, res, req.body)
|
|
273
166
|
} catch (error) {
|
|
274
|
-
logger.error('Error handling MCP request:', error)
|
|
167
|
+
logger.error('[stateless] Error handling MCP request:', error)
|
|
275
168
|
if (!res.headersSent) {
|
|
276
169
|
res.status(500).json({
|
|
277
170
|
jsonrpc: '2.0',
|
|
@@ -282,27 +175,16 @@ export async function startStatelessBridge(args: StatelessBridgeArgs) {
|
|
|
282
175
|
}
|
|
283
176
|
})
|
|
284
177
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
res.writeHead(405).end(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
app.delete(path, (_req, res) => {
|
|
297
|
-
logger.info('Received DELETE — method not allowed in stateless mode')
|
|
298
|
-
res.writeHead(405).end(
|
|
299
|
-
JSON.stringify({
|
|
300
|
-
jsonrpc: '2.0',
|
|
301
|
-
error: { code: -32000, message: 'Method not allowed.' },
|
|
302
|
-
id: null,
|
|
303
|
-
}),
|
|
304
|
-
)
|
|
305
|
-
})
|
|
178
|
+
// GET/DELETE not allowed in stateless mode
|
|
179
|
+
const methodNotAllowed = (_req: any, res: any) => {
|
|
180
|
+
res.writeHead(405).end(JSON.stringify({
|
|
181
|
+
jsonrpc: '2.0',
|
|
182
|
+
error: { code: -32000, message: 'Method not allowed.' },
|
|
183
|
+
id: null,
|
|
184
|
+
}))
|
|
185
|
+
}
|
|
186
|
+
app.get(path, methodNotAllowed)
|
|
187
|
+
app.delete(path, methodNotAllowed)
|
|
306
188
|
|
|
307
189
|
app.listen(port, () => {
|
|
308
190
|
logger.info(`Listening on port ${port}`)
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import yargs from 'yargs'
|
|
4
4
|
import { hideBin } from 'yargs/helpers'
|
|
5
|
-
import { getLogger
|
|
5
|
+
import { getLogger } from './lib/getLogger.js'
|
|
6
6
|
import { parseCorsOrigin } from './lib/cors.js'
|
|
7
7
|
import { parseHeaders } from './lib/headers.js'
|
|
8
8
|
import { startStatelessBridge } from './gateways/statelessBridge.js'
|
|
9
9
|
import { startStatefulBridge } from './gateways/statefulBridge.js'
|
|
10
|
-
import type { AuthConfig } from './types.js'
|
|
10
|
+
import type { AuthConfig, LogLevel } from './types.js'
|
|
11
11
|
|
|
12
12
|
const argv = await yargs(hideBin(process.argv))
|
|
13
13
|
.scriptName('platf-bridge')
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process management for stdio-based MCP servers.
|
|
3
|
+
*
|
|
4
|
+
* Handles spawning child processes, parsing JSON-RPC messages from stdout,
|
|
5
|
+
* and writing messages to stdin.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, type ChildProcess } from 'child_process'
|
|
9
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
|
|
10
|
+
import type { Logger } from '../types.js'
|
|
11
|
+
|
|
12
|
+
export interface ChildProcessCallbacks {
|
|
13
|
+
/** Called when a JSON-RPC message is received from the child */
|
|
14
|
+
onMessage: (msg: JSONRPCMessage) => void
|
|
15
|
+
/** Called when the child process exits */
|
|
16
|
+
onExit: (code: number | null, signal: string | null, stderrOutput: string) => void
|
|
17
|
+
/** Called when there's an error spawning or communicating with the child */
|
|
18
|
+
onError?: (err: Error) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ManagedChildProcess {
|
|
22
|
+
/** The underlying child process */
|
|
23
|
+
child: ChildProcess
|
|
24
|
+
/** Send a JSON-RPC message to the child's stdin */
|
|
25
|
+
send: (msg: JSONRPCMessage) => boolean
|
|
26
|
+
/** Kill the child process */
|
|
27
|
+
kill: () => void
|
|
28
|
+
/** Track a request ID as pending (for error reporting on exit) */
|
|
29
|
+
trackRequest: (id: string | number) => void
|
|
30
|
+
/** Mark a request ID as completed */
|
|
31
|
+
completeRequest: (id: string | number) => void
|
|
32
|
+
/** Get all pending request IDs */
|
|
33
|
+
getPendingRequests: () => Set<string | number>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Spawn a child process and set up JSON-RPC message handling.
|
|
38
|
+
*
|
|
39
|
+
* The child is expected to speak JSON-RPC over stdio (one message per line).
|
|
40
|
+
*/
|
|
41
|
+
export function spawnManagedChild(
|
|
42
|
+
command: string,
|
|
43
|
+
logger: Logger,
|
|
44
|
+
callbacks: ChildProcessCallbacks,
|
|
45
|
+
): ManagedChildProcess {
|
|
46
|
+
const pendingRequestIds = new Set<string | number>()
|
|
47
|
+
let stderrOutput = ''
|
|
48
|
+
let buffer = ''
|
|
49
|
+
|
|
50
|
+
const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
51
|
+
|
|
52
|
+
logger.info(`[child] Spawned process, pid=${child.pid}`)
|
|
53
|
+
|
|
54
|
+
child.on('spawn', () => {
|
|
55
|
+
logger.info(`[child] spawn event fired, pid=${child.pid}`)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
child.on('error', (err) => {
|
|
59
|
+
logger.error(`[child] error event: ${err.message}`)
|
|
60
|
+
callbacks.onError?.(err)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
child.on('exit', (code, signal) => {
|
|
64
|
+
logger.info(`[child] exited: code=${code}, signal=${signal}`)
|
|
65
|
+
callbacks.onExit(code, signal, stderrOutput)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
69
|
+
const chunkStr = chunk.toString('utf8')
|
|
70
|
+
logger.info(`[child] stdout received ${chunk.length} bytes`)
|
|
71
|
+
buffer += chunkStr
|
|
72
|
+
|
|
73
|
+
const lines = buffer.split(/\r?\n/)
|
|
74
|
+
buffer = lines.pop() ?? ''
|
|
75
|
+
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
if (!line.trim()) continue
|
|
78
|
+
try {
|
|
79
|
+
const jsonMsg = JSON.parse(line) as JSONRPCMessage
|
|
80
|
+
// Auto-complete request tracking for responses
|
|
81
|
+
if ('id' in jsonMsg && jsonMsg.id !== undefined) {
|
|
82
|
+
pendingRequestIds.delete(jsonMsg.id)
|
|
83
|
+
}
|
|
84
|
+
logger.info(`[child] → message: ${line.slice(0, 200)}${line.length > 200 ? '...' : ''}`)
|
|
85
|
+
callbacks.onMessage(jsonMsg)
|
|
86
|
+
} catch {
|
|
87
|
+
logger.error(`[child] non-JSON output: ${line}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
93
|
+
const text = chunk.toString('utf8')
|
|
94
|
+
stderrOutput += text
|
|
95
|
+
logger.error(`[child] stderr: ${text}`)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
child.stdin.on('error', (err) => {
|
|
99
|
+
logger.error(`[child] stdin error: ${err.message}`)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
child,
|
|
104
|
+
send: (msg: JSONRPCMessage) => {
|
|
105
|
+
const payload = JSON.stringify(msg) + '\n'
|
|
106
|
+
logger.info(`[child] ← message: ${JSON.stringify(msg).slice(0, 200)}`)
|
|
107
|
+
return child.stdin.write(payload)
|
|
108
|
+
},
|
|
109
|
+
kill: () => {
|
|
110
|
+
child.kill()
|
|
111
|
+
},
|
|
112
|
+
trackRequest: (id: string | number) => {
|
|
113
|
+
pendingRequestIds.add(id)
|
|
114
|
+
},
|
|
115
|
+
completeRequest: (id: string | number) => {
|
|
116
|
+
pendingRequestIds.delete(id)
|
|
117
|
+
},
|
|
118
|
+
getPendingRequests: () => pendingRequestIds,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Shared configuration constants */
|
|
2
|
+
|
|
3
|
+
export const VERSION = '1.0.0'
|
|
4
|
+
|
|
5
|
+
/** Default MCP protocol version for auto-initialization */
|
|
6
|
+
export const DEFAULT_PROTOCOL_VERSION = '2025-03-26'
|
|
7
|
+
|
|
8
|
+
/** Bridge server name used in MCP handshakes */
|
|
9
|
+
export const SERVER_NAME = 'platf-bridge'
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - GET /.well-known/oauth-authorization-server[/*] (RFC 8414 — proxied from issuer)
|
|
7
7
|
* - POST /oauth/register (Pseudo-DCR — RFC 7591)
|
|
8
8
|
*
|
|
9
|
+
* OAuth proxy routes (/oauth/authorize, /oauth/token, /jwks) are in oauthProxy.ts.
|
|
10
|
+
*
|
|
9
11
|
* These endpoints are unauthenticated — they must be accessible to
|
|
10
12
|
* any client performing OAuth discovery before obtaining a token.
|
|
11
13
|
*/
|
|
@@ -87,10 +89,15 @@ export function createDiscoveryRouter(auth: AuthConfig, logger: Logger): Router
|
|
|
87
89
|
|
|
88
90
|
const metadata = (await upstream.json()) as Record<string, unknown>
|
|
89
91
|
|
|
90
|
-
// Patch
|
|
92
|
+
// Patch all OAuth endpoints to point to our proxy
|
|
91
93
|
const scheme = req.protocol
|
|
92
94
|
const host = req.get('host')
|
|
93
|
-
|
|
95
|
+
const bridgeOrigin = `${scheme}://${host}`
|
|
96
|
+
metadata.issuer = bridgeOrigin
|
|
97
|
+
metadata.authorization_endpoint = `${bridgeOrigin}/oauth/authorize`
|
|
98
|
+
metadata.token_endpoint = `${bridgeOrigin}/oauth/token`
|
|
99
|
+
metadata.registration_endpoint = `${bridgeOrigin}/oauth/register`
|
|
100
|
+
metadata.jwks_uri = `${bridgeOrigin}/jwks`
|
|
94
101
|
|
|
95
102
|
res.json(metadata)
|
|
96
103
|
} catch (err: any) {
|
|
@@ -120,71 +127,5 @@ export function createDiscoveryRouter(auth: AuthConfig, logger: Logger): Router
|
|
|
120
127
|
})
|
|
121
128
|
})
|
|
122
129
|
|
|
123
|
-
/**
|
|
124
|
-
* OAuth Authorization Endpoint — Redirect to upstream
|
|
125
|
-
*
|
|
126
|
-
* Since the bridge advertises itself as the authorization_server,
|
|
127
|
-
* clients will attempt to call /oauth/authorize here. We redirect
|
|
128
|
-
* to the upstream auth server, preserving all query parameters.
|
|
129
|
-
*/
|
|
130
|
-
router.get('/oauth/authorize', (req: Request, res: Response) => {
|
|
131
|
-
const upstreamUrl = new URL(`${auth.issuer}/oauth/authorize`)
|
|
132
|
-
// Copy all query params to upstream
|
|
133
|
-
for (const [key, value] of Object.entries(req.query)) {
|
|
134
|
-
if (typeof value === 'string') {
|
|
135
|
-
upstreamUrl.searchParams.set(key, value)
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
logger.info(`[discovery] Redirecting /oauth/authorize to ${upstreamUrl.toString().slice(0, 100)}...`)
|
|
139
|
-
res.redirect(upstreamUrl.toString())
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* OAuth Token Endpoint — Proxy to upstream
|
|
144
|
-
*
|
|
145
|
-
* Proxies token exchange requests to the upstream auth server.
|
|
146
|
-
*/
|
|
147
|
-
router.post('/oauth/token', async (req: Request, res: Response) => {
|
|
148
|
-
try {
|
|
149
|
-
const upstreamUrl = `${auth.issuer}/oauth/token`
|
|
150
|
-
logger.info('[discovery] Proxying /oauth/token to upstream')
|
|
151
|
-
|
|
152
|
-
const upstreamRes = await fetch(upstreamUrl, {
|
|
153
|
-
method: 'POST',
|
|
154
|
-
headers: {
|
|
155
|
-
'Content-Type': req.get('Content-Type') || 'application/x-www-form-urlencoded',
|
|
156
|
-
},
|
|
157
|
-
body: req.get('Content-Type')?.includes('application/json')
|
|
158
|
-
? JSON.stringify(req.body)
|
|
159
|
-
: new URLSearchParams(req.body as Record<string, string>).toString(),
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
const data = await upstreamRes.text()
|
|
163
|
-
res.status(upstreamRes.status)
|
|
164
|
-
res.set('Content-Type', upstreamRes.headers.get('Content-Type') || 'application/json')
|
|
165
|
-
res.send(data)
|
|
166
|
-
} catch (err: any) {
|
|
167
|
-
logger.error('[discovery] Error proxying /oauth/token:', err.message ?? err)
|
|
168
|
-
res.status(502).json({ error: 'upstream_error' })
|
|
169
|
-
}
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* JWKS Endpoint — Proxy to upstream
|
|
174
|
-
*
|
|
175
|
-
* Proxies JSON Web Key Set requests for token verification.
|
|
176
|
-
*/
|
|
177
|
-
router.get('/jwks', async (req: Request, res: Response) => {
|
|
178
|
-
try {
|
|
179
|
-
const upstreamUrl = `${auth.issuer}/jwks`
|
|
180
|
-
const upstreamRes = await fetch(upstreamUrl)
|
|
181
|
-
const data = await upstreamRes.json()
|
|
182
|
-
res.json(data)
|
|
183
|
-
} catch (err: any) {
|
|
184
|
-
logger.error('[discovery] Error proxying /jwks:', err.message ?? err)
|
|
185
|
-
res.status(502).json({ error: 'upstream_error' })
|
|
186
|
-
}
|
|
187
|
-
})
|
|
188
|
-
|
|
189
130
|
return router
|
|
190
131
|
}
|