3
3
4
4
Copyright 2025
5
5
SPDX-License-Identifier: Apache-2.0
6
- Authors: Mihai Criveti
6
+ Authors: Mihai Criveti, Manav Gupta
7
+
8
+ You can now run the bridge in either direction:
9
+
10
+ - stdio to SSE (expose local stdio MCP server over SSE)
11
+ - SSE to stdio (bridge remote SSE endpoint to local stdio)
7
12
8
- Only the stdio→SSE direction is implemented for now.
9
13
10
14
Usage
11
15
-----
16
20
curl -N http://localhost:9000/sse # receive the stream
17
21
18
22
# 3. send a test echo request
19
- curl -X POST http://localhost:9000/message \
20
- -H 'Content-Type: application/json' \
23
+ curl -X POST http://localhost:9000/message \\
24
+ -H 'Content-Type: application/json' \\
21
25
-d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{"value":"hi"}}'
22
26
23
27
# 4. proper MCP handshake and tool listing
24
- curl -X POST http://localhost:9000/message \
25
- -H 'Content-Type: application/json' \
28
+ curl -X POST http://localhost:9000/message \\
29
+ -H 'Content-Type: application/json' \\
26
30
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"demo","version":"0.0.1"}}}'
27
31
28
- curl -X POST http://localhost:9000/message \
29
- -H 'Content-Type: application/json' \
32
+ curl -X POST http://localhost:9000/message \\
33
+ -H 'Content-Type: application/json' \\
30
34
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
31
35
32
36
The SSE stream now emits JSON-RPC responses as `event: message` frames and sends
@@ -162,9 +166,23 @@ def _build_fastapi(
162
166
keep_alive : int = KEEP_ALIVE_INTERVAL ,
163
167
sse_path : str = "/sse" ,
164
168
message_path : str = "/message" ,
169
+ cors_origins : Optional [List [str ]] = None ,
165
170
) -> FastAPI :
166
171
app = FastAPI ()
167
172
173
+ # Add CORS middleware if origins specified
174
+ if cors_origins :
175
+ # Third-Party
176
+ from fastapi .middleware .cors import CORSMiddleware
177
+
178
+ app .add_middleware (
179
+ CORSMiddleware ,
180
+ allow_origins = cors_origins ,
181
+ allow_credentials = True ,
182
+ allow_methods = ["*" ],
183
+ allow_headers = ["*" ],
184
+ )
185
+
168
186
# ----- GET /sse ---------------------------------------------------------#
169
187
@app .get (sse_path )
170
188
async def get_sse (request : Request ) -> EventSourceResponse : # noqa: D401
@@ -263,11 +281,11 @@ async def health() -> Response: # noqa: D401
263
281
def _parse_args (argv : Sequence [str ]) -> argparse .Namespace :
264
282
p = argparse .ArgumentParser (
265
283
prog = "mcpgateway.translate" ,
266
- description = "Bridges stdio JSON-RPC to SSE." ,
284
+ description = "Bridges stdio JSON-RPC to SSE or SSE to stdio ." ,
267
285
)
268
286
src = p .add_mutually_exclusive_group (required = True )
269
287
src .add_argument ("--stdio" , help = 'Command to run, e.g. "uv run mcp-server-git"' )
270
- src .add_argument ("--sse" , help = "[NOT IMPLEMENTED] " )
288
+ src .add_argument ("--sse" , help = "Remote SSE endpoint URL " )
271
289
src .add_argument ("--streamableHttp" , help = "[NOT IMPLEMENTED]" )
272
290
273
291
p .add_argument ("--port" , type = int , default = 8000 , help = "HTTP port to bind" )
@@ -277,19 +295,28 @@ def _parse_args(argv: Sequence[str]) -> argparse.Namespace:
277
295
choices = ["debug" , "info" , "warning" , "error" , "critical" ],
278
296
help = "Log level" ,
279
297
)
298
+ p .add_argument (
299
+ "--cors" ,
300
+ nargs = "*" ,
301
+ help = "CORS allowed origins (e.g., --cors https://app.example.com)" ,
302
+ )
303
+ p .add_argument (
304
+ "--oauth2Bearer" ,
305
+ help = "OAuth2 Bearer token for authentication" ,
306
+ )
280
307
281
308
args = p .parse_args (argv )
282
- if args .sse or args . streamableHttp :
283
- raise NotImplementedError ("Only --stdio → SSE is available in this build." )
309
+ if args .streamableHttp :
310
+ raise NotImplementedError ("Only --stdio → SSE and --sse → stdio are available in this build." )
284
311
return args
285
312
286
313
287
- async def _run_stdio_to_sse (cmd : str , port : int , log_level : str = "info" ) -> None :
314
+ async def _run_stdio_to_sse (cmd : str , port : int , log_level : str = "info" , cors : Optional [ List [ str ]] = None ) -> None :
288
315
pubsub = _PubSub ()
289
316
stdio = StdIOEndpoint (cmd , pubsub )
290
317
await stdio .start ()
291
318
292
- app = _build_fastapi (pubsub , stdio )
319
+ app = _build_fastapi (pubsub , stdio , cors_origins = cors )
293
320
config = uvicorn .Config (
294
321
app ,
295
322
host = "0.0.0.0" ,
@@ -319,14 +346,54 @@ async def _shutdown() -> None:
319
346
await _shutdown () # final cleanup
320
347
321
348
349
+ async def _run_sse_to_stdio (url : str , oauth2_bearer : Optional [str ], log_level : str = "info" ) -> None :
350
+ # Third-Party
351
+ import httpx
352
+
353
+ headers = {}
354
+ if oauth2_bearer :
355
+ headers ["Authorization" ] = f"Bearer { oauth2_bearer } "
356
+
357
+ async with httpx .AsyncClient (headers = headers , timeout = None ) as client :
358
+ process = await asyncio .create_subprocess_shell (
359
+ "cat" , # Placeholder command; replace with actual stdio server command if needed
360
+ stdin = asyncio .subprocess .PIPE ,
361
+ stdout = asyncio .subprocess .PIPE ,
362
+ stderr = sys .stderr ,
363
+ )
364
+
365
+ async def read_stdout ():
366
+ assert process .stdout
367
+ while True :
368
+ line = await process .stdout .readline ()
369
+ if not line :
370
+ break
371
+ print (line .decode ().rstrip ())
372
+
373
+ async def pump_sse_to_stdio ():
374
+ async with client .stream ("GET" , url ) as response :
375
+ async for line in response .aiter_lines ():
376
+ if line .startswith ("data: " ):
377
+ data = line [6 :]
378
+ if data and data != "{}" :
379
+ if process .stdin :
380
+ process .stdin .write ((data + "\n " ).encode ())
381
+ await process .stdin .drain ()
382
+
383
+ await asyncio .gather (read_stdout (), pump_sse_to_stdio ())
384
+
385
+
322
386
def main (argv : Optional [Sequence [str ]] | None = None ) -> None : # entry-point
323
387
args = _parse_args (argv or sys .argv [1 :])
324
388
logging .basicConfig (
325
389
level = getattr (logging , args .logLevel .upper (), logging .INFO ),
326
390
format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" ,
327
391
)
328
392
try :
329
- asyncio .run (_run_stdio_to_sse (args .stdio , args .port , args .logLevel ))
393
+ if args .stdio :
394
+ asyncio .run (_run_stdio_to_sse (args .stdio , args .port , args .logLevel , args .cors ))
395
+ elif args .sse :
396
+ asyncio .run (_run_sse_to_stdio (args .sse , args .oauth2Bearer , args .logLevel ))
330
397
except KeyboardInterrupt :
331
398
print ("" ) # restore shell prompt
332
399
sys .exit (0 )
0 commit comments