feat: add built-in streamable-http gateway

This commit is contained in:
gpt-5-codex 2025-10-11 02:19:39 +02:00 committed by Andre Heinecke
parent 2708a6ff5f
commit 341e48a696
5 changed files with 201 additions and 3 deletions

View File

@ -15,6 +15,7 @@ from .proxy import MCPBrowser
from .config import ConfigLoader
from .daemon import MCPBrowserDaemon, MCPBrowserClient, get_socket_path, is_daemon_running, kill_daemon_with_children
from .logging_config import setup_logging, get_logger
from .http_gateway import run_streamable_http_gateway
def build_mcp_request(args) -> Dict[str, Any]:
@ -661,7 +662,7 @@ Environment:
)
parser.add_argument("--server", "-s", help="Target MCP server name (see --list-servers)")
parser.add_argument("--config", "-c", help="Custom configuration file path")
parser.add_argument("--mode", choices=["interactive", "server", "daemon"],
parser.add_argument("--mode", choices=["interactive", "server", "daemon", "streamable-http"],
default="interactive", help="Operating mode (default: interactive)")
parser.add_argument("--no-sparse", action="store_true",
help="Disable sparse mode (show all tools)")
@ -711,6 +712,14 @@ Environment:
help="Additional form parameters for OAuth token requests (repeatable)")
parser.add_argument("--oauth-token",
help="Static OAuth bearer token (applies Authorization header)")
parser.add_argument("--http-host", default="127.0.0.1",
help="Host/interface for streamable-http gateway (when --mode streamable-http)")
parser.add_argument("--http-port", type=int, default=0,
help="TCP port for streamable-http gateway (default 0 = random)")
parser.add_argument("--http-path", default="/mcp",
help="HTTP path prefix for streamable-http gateway (default /mcp)")
parser.add_argument("--http-allow-origin",
help="Optional Access-Control-Allow-Origin value for streamable-http gateway")
# MCP method commands
subparsers = parser.add_subparsers(dest="command", help="MCP methods")
@ -875,6 +884,16 @@ Environment:
# Run as daemon
socket_path = get_socket_path(args.server)
asyncio.run(run_daemon_mode(browser, socket_path))
elif args.mode == "streamable-http":
asyncio.run(
run_streamable_http_gateway(
browser,
host=args.http_host,
port=args.http_port,
path=args.http_path,
allow_origin=args.http_allow_origin,
)
)
else:
# Interactive mode - can use daemon if available
socket_path = get_socket_path(args.server)

166
mcp_browser/http_gateway.py Normal file
View File

@ -0,0 +1,166 @@
"""
HTTP gateway for exposing MCP Browser via the Streamable HTTP transport.
This module provides a lightweight HTTP server that accepts JSON-RPC requests
over HTTP POST (and optionally Server-Sent Events in the future) and forwards
them to an underlying MCPBrowser instance.
"""
from __future__ import annotations
import asyncio
import json
from typing import Optional, Dict, Any
from aiohttp import web
from .proxy import MCPBrowser
from .logging_config import get_logger
class StreamableHTTPGateway:
"""Expose an MCPBrowser instance via HTTP."""
def __init__(
self,
browser: MCPBrowser,
*,
host: str = "127.0.0.1",
port: int = 0,
path: str = "/mcp",
allow_origin: Optional[str] = None,
) -> None:
self.browser = browser
self.host = host
self.port = port
self.path = path.rstrip("/") or "/mcp"
self.allow_origin = allow_origin
self.logger = get_logger(__name__)
self._app: Optional[web.Application] = None
self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None
self._shutdown_event = asyncio.Event()
async def start(self) -> None:
"""Start the HTTP server."""
if self._runner:
return
await self.browser.initialize()
self._app = web.Application()
self._app.add_routes(
[
web.post(self.path, self._handle_rpc_request),
web.post(f"{self.path}/", self._handle_rpc_request),
web.get(self.path, self._handle_get_not_supported),
web.get(f"{self.path}/", self._handle_get_not_supported),
]
)
self._runner = web.AppRunner(self._app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self.host, self.port)
await self._site.start()
addresses = []
if self._runner.addresses:
for addr in self._runner.addresses:
if isinstance(addr, tuple):
addresses.append(f"http://{addr[0]}:{addr[1]}{self.path}")
self.logger.info(
"Streamable HTTP gateway listening on %s",
", ".join(addresses) if addresses else f"{self.host}:{self.port}",
)
async def close(self) -> None:
"""Shutdown HTTP server and underlying browser."""
if self._site:
await self._site.stop()
self._site = None
if self._runner:
await self._runner.cleanup()
self._runner = None
if self._app:
await self._app.shutdown()
await self._app.cleanup()
self._app = None
await self.browser.close()
self._shutdown_event.set()
async def serve_forever(self) -> None:
"""Run until cancelled."""
await self.start()
await self._shutdown_event.wait()
async def _handle_rpc_request(self, request: web.Request) -> web.StreamResponse:
"""Handle POST /mcp requests."""
try:
payload = await request.json(loads=json.loads)
if not isinstance(payload, dict):
raise ValueError("JSON-RPC payload must be a JSON object")
except Exception as exc:
self.logger.warning("Invalid JSON payload: %s", exc)
return web.json_response(
{
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32700, "message": "Invalid JSON payload"},
},
status=400,
)
try:
response = await self.browser.call(payload)
except Exception as exc:
self.logger.error("Error processing request: %s", exc)
response = {
"jsonrpc": "2.0",
"id": payload.get("id"),
"error": {"code": -32603, "message": str(exc)},
}
headers: Dict[str, str] = {}
if self.allow_origin:
headers["Access-Control-Allow-Origin"] = self.allow_origin
return web.json_response(response, headers=headers)
async def _handle_get_not_supported(self, request: web.Request) -> web.StreamResponse:
"""Placeholder handler for SSE subscription attempts."""
return web.json_response(
{
"error": {
"code": -32601,
"message": "Server-initiated streams are not yet supported. Use POST requests.",
}
},
status=405,
)
async def run_streamable_http_gateway(
browser: MCPBrowser,
*,
host: str,
port: int,
path: str,
allow_origin: Optional[str] = None,
) -> None:
"""Utility to run the gateway until cancelled."""
gateway = StreamableHTTPGateway(
browser,
host=host,
port=port,
path=path,
allow_origin=allow_origin,
)
try:
await gateway.serve_forever()
except asyncio.CancelledError:
raise
finally:
await gateway.close()

View File

@ -1,3 +1,4 @@
pyyaml>=6.0
jsonpath-ng>=1.5.3
httpx>=0.27
aiohttp>=3.9

View File

@ -318,6 +318,7 @@ setup(
"jsonpath-ng>=1.6.0",
"pyyaml>=6.0",
"httpx>=0.27",
"aiohttp>=3.9",
"typing-extensions>=4.0.0;python_version<'3.11'",
],
extras_require={

View File

@ -1,5 +1,5 @@
[Unit]
Description=Run mcp-browser in MCP server mode
Description=Run mcp-browser in MCP stdio server mode (requires external HTTP bridge)
Documentation=https://github.com/Xilope0/mcp-browser
After=network-online.target
Wants=network-online.target
@ -10,14 +10,21 @@ Environment="PYTHONUNBUFFERED=1"
Environment="MCP_BROWSER_BIN=%h/.local/bin/mcp-browser"
Environment="MCP_BROWSER_CONFIG=%h/.config/mcp-browser/config.yaml"
Environment="MCP_BROWSER_SERVER=default"
Environment="MCP_BROWSER_MODE=server"
Environment="MCP_BROWSER_MODE=streamable-http"
Environment="MCP_BROWSER_NO_BUILTIN=false"
Environment="MCP_BROWSER_TRANSPORT="
Environment="MCP_BROWSER_TRANSPORT_URL="
Environment="MCP_BROWSER_EXTRA_ARGS="
Environment="MCP_BROWSER_HTTP_HOST=127.0.0.1"
Environment="MCP_BROWSER_HTTP_PORT=0"
Environment="MCP_BROWSER_HTTP_PATH=/mcp"
Environment="MCP_BROWSER_HTTP_ALLOW_ORIGIN="
EnvironmentFile=-%h/.config/mcp-browser/browser.env
ExecStart=/usr/bin/env bash -lc 'set -eu
# NOTE: mcp-browser speaks MCP over stdio only. To expose HTTP/SSE for
# clients (e.g. OpenAI), place an HTTP bridge or reverse proxy in front of this
# unit and pipe requests to its stdin/stdout (see examples/mcp.conf).
ARGS=()
if [[ "$MCP_BROWSER_NO_BUILTIN" == "true" ]]; then
ARGS+=(--no-builtin)
@ -32,6 +39,10 @@ exec "$MCP_BROWSER_BIN" \
--mode "$MCP_BROWSER_MODE" \
--config "$MCP_BROWSER_CONFIG" \
--server "$MCP_BROWSER_SERVER" \
--http-host "$MCP_BROWSER_HTTP_HOST" \
--http-port "$MCP_BROWSER_HTTP_PORT" \
--http-path "$MCP_BROWSER_HTTP_PATH" \
${MCP_BROWSER_HTTP_ALLOW_ORIGIN:+--http-allow-origin "$MCP_BROWSER_HTTP_ALLOW_ORIGIN"} \
"${ARGS[@]}" \
$MCP_BROWSER_EXTRA_ARGS'