Skip to content

Commit c831f70

Browse files
authored
Merge pull request #128 from manavgup/feature/mcpgateway.translate
Implemented Transport-Translation Bridge (mcpgateway.translate)
2 parents 77e22fc + 6fe9d7b commit c831f70

File tree

5 files changed

+227
-18
lines changed

5 files changed

+227
-18
lines changed

docs/docs/using/.pages

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
nav:
22
- index.md
33
- mcpgateway-wrapper.md
4+
- mcpgateway-translate.md
45
- Clients: clients
56
- Agents: agents
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# MCP Gateway StdIO to SSE Bridge (`mcpgateway-translate`)
2+
3+
`mcpgateway-translate` is a lightweight bridge that connects a JSON-RPC server
4+
running over StdIO to an HTTP/SSE interface, or consumes a remote SSE stream
5+
and forwards messages to a local StdIO process.
6+
7+
Supported modes:
8+
9+
1. StdIO to SSE – serve a local subprocess over HTTP with SSE output
10+
2. SSE to StdIO – subscribe to a remote SSE stream and forward messages to a local process
11+
12+
---
13+
14+
## Features
15+
16+
| Feature | Description |
17+
|---------|-------------|
18+
| Bidirectional bridging | Supports both StdIO to SSE and SSE to StdIO |
19+
| Keep-alive frames | Emits `keepalive` events every 30 seconds |
20+
| Endpoint bootstrapping | Sends a unique message POST endpoint per client session |
21+
| CORS support | Configure allowed origins via `--cors` |
22+
| OAuth2 support | Use `--oauth2Bearer` to authorize remote SSE connections |
23+
| Health check | Provides a `/healthz` endpoint for liveness probes |
24+
| Logging control | Adjustable log verbosity with `--logLevel` |
25+
| Graceful shutdown | Cleans up subprocess and server on termination signals |
26+
27+
---
28+
29+
## Quick Start
30+
31+
### Expose a local StdIO server over SSE
32+
33+
```bash
34+
mcpgateway-translate \
35+
--stdio "uvenv run mcp-server-git" \
36+
--port 9000
37+
```
38+
39+
Access the SSE stream at:
40+
41+
```
42+
http://localhost:9000/sse
43+
```
44+
45+
### Bridge a remote SSE endpoint to a local process
46+
47+
```bash
48+
mcpgateway-translate \
49+
--sse "https://corp.example.com/mcp" \
50+
--oauth2Bearer "your-token"
51+
```
52+
53+
---
54+
55+
## Command-Line Options
56+
57+
```
58+
mcpgateway-translate [--stdio CMD | --sse URL | --streamableHttp URL] [options]
59+
```
60+
61+
### Required (one of)
62+
63+
* `--stdio <command>`
64+
Start a local process whose stdout will be streamed as SSE and stdin will receive backchannel messages.
65+
66+
* `--sse <url>`
67+
Connect to a remote SSE stream and forward messages to a local subprocess.
68+
69+
* `--streamableHttp <url>`
70+
Not implemented in this build. Raises an error.
71+
72+
### Optional
73+
74+
* `--port <number>`
75+
HTTP server port when using --stdio mode (default: 8000)
76+
77+
* `--cors <origins>`
78+
One or more allowed origins for CORS (space-separated)
79+
80+
* `--oauth2Bearer <token>`
81+
Bearer token to include in Authorization header when connecting to remote SSE
82+
83+
* `--logLevel <level>`
84+
Logging level (default: info). Options: debug, info, warning, error, critical
85+
86+
---
87+
88+
## HTTP API (when using --stdio)
89+
90+
### GET /sse
91+
92+
Streams JSON-RPC responses as SSE. Each connection receives:
93+
94+
* `event: endpoint` – the URL for backchannel POST
95+
* `event: keepalive` – periodic keepalive signal
96+
* `event: message` – forwarded output from subprocess
97+
98+
### POST /message
99+
100+
Send a JSON-RPC message to the subprocess. Returns HTTP 202 on success, or 400 for invalid JSON.
101+
102+
### GET /healthz
103+
104+
Health check endpoint. Always responds with `ok`.
105+
106+
---
107+
108+
## Example Use Cases
109+
110+
### 1. Browser integration
111+
112+
```bash
113+
mcpgateway-translate \
114+
--stdio "uvenv run mcp-server-git" \
115+
--port 9000 \
116+
--cors "https://myapp.com"
117+
```
118+
119+
Then connect the frontend to:
120+
121+
```
122+
http://localhost:9000/sse
123+
```
124+
125+
### 2. Connect remote server to local CLI tools
126+
127+
```bash
128+
mcpgateway-translate \
129+
--sse "https://corp.example.com/mcp" \
130+
--oauth2Bearer "$TOKEN" \
131+
--logLevel debug
132+
```
133+
134+
---
135+
136+
## Notes
137+
138+
* Only StdIO to SSE and SSE to StdIO bridging are implemented.
139+
* Any use of `--streamableHttp` will raise a NotImplementedError.

mcpgateway/translate.py

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
44
Copyright 2025
55
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)
712
8-
Only the stdio→SSE direction is implemented for now.
913
1014
Usage
1115
-----
@@ -16,17 +20,17 @@
1620
curl -N http://localhost:9000/sse # receive the stream
1721
1822
# 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' \\
2125
-d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{"value":"hi"}}'
2226
2327
# 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' \\
2630
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"demo","version":"0.0.1"}}}'
2731
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' \\
3034
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
3135
3236
The SSE stream now emits JSON-RPC responses as `event: message` frames and sends
@@ -162,9 +166,23 @@ def _build_fastapi(
162166
keep_alive: int = KEEP_ALIVE_INTERVAL,
163167
sse_path: str = "/sse",
164168
message_path: str = "/message",
169+
cors_origins: Optional[List[str]] = None,
165170
) -> FastAPI:
166171
app = FastAPI()
167172

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+
168186
# ----- GET /sse ---------------------------------------------------------#
169187
@app.get(sse_path)
170188
async def get_sse(request: Request) -> EventSourceResponse: # noqa: D401
@@ -263,11 +281,11 @@ async def health() -> Response: # noqa: D401
263281
def _parse_args(argv: Sequence[str]) -> argparse.Namespace:
264282
p = argparse.ArgumentParser(
265283
prog="mcpgateway.translate",
266-
description="Bridges stdio JSON-RPC to SSE.",
284+
description="Bridges stdio JSON-RPC to SSE or SSE to stdio.",
267285
)
268286
src = p.add_mutually_exclusive_group(required=True)
269287
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")
271289
src.add_argument("--streamableHttp", help="[NOT IMPLEMENTED]")
272290

273291
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:
277295
choices=["debug", "info", "warning", "error", "critical"],
278296
help="Log level",
279297
)
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+
)
280307

281308
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.")
284311
return args
285312

286313

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:
288315
pubsub = _PubSub()
289316
stdio = StdIOEndpoint(cmd, pubsub)
290317
await stdio.start()
291318

292-
app = _build_fastapi(pubsub, stdio)
319+
app = _build_fastapi(pubsub, stdio, cors_origins=cors)
293320
config = uvicorn.Config(
294321
app,
295322
host="0.0.0.0",
@@ -319,14 +346,54 @@ async def _shutdown() -> None:
319346
await _shutdown() # final cleanup
320347

321348

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+
322386
def main(argv: Optional[Sequence[str]] | None = None) -> None: # entry-point
323387
args = _parse_args(argv or sys.argv[1:])
324388
logging.basicConfig(
325389
level=getattr(logging, args.logLevel.upper(), logging.INFO),
326390
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
327391
)
328392
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))
330397
except KeyboardInterrupt:
331398
print("") # restore shell prompt
332399
sys.exit(0)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ Changelog = "https://github.com/IBM/mcp-context-forge/blob/main/CHANGELOG.md"
175175
# --------------------------------------------------------------------
176176
[project.scripts]
177177
mcpgateway = "mcpgateway.cli:main"
178+
mcpgateway-translate = "mcpgateway.translate.cli:main"
178179

179180
# --------------------------------------------------------------------
180181
# 🔧 setuptools-specific configuration

tests/unit/mcpgateway/test_translate.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,10 @@ def test_parse_args_ok(translate):
212212
assert ns.stdio == "echo hi" and ns.port == 9001
213213

214214

215-
def test_parse_args_not_implemented(translate):
216-
with pytest.raises(NotImplementedError):
217-
translate._parse_args(["--sse", "x"])
215+
def test_parse_args_sse_ok(translate):
216+
ns = translate._parse_args(["--sse", "http://upstream.example/sse"])
217+
assert ns.sse == "http://upstream.example/sse"
218+
assert ns.stdio is None
218219

219220

220221
@pytest.mark.asyncio

0 commit comments

Comments
 (0)