From 341e48a696548e85bff33193f4126ad197d7156d Mon Sep 17 00:00:00 2001 From: gpt-5-codex Date: Sat, 11 Oct 2025 02:19:39 +0200 Subject: [PATCH] feat: add built-in streamable-http gateway --- mcp_browser/__main__.py | 21 +++- mcp_browser/http_gateway.py | 166 ++++++++++++++++++++++++++++++ requirements.txt | 1 + setup.py | 1 + systemd/mcp-browser-proxy.service | 15 ++- 5 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 mcp_browser/http_gateway.py diff --git a/mcp_browser/__main__.py b/mcp_browser/__main__.py index 28a3ab7..245ab3b 100644 --- a/mcp_browser/__main__.py +++ b/mcp_browser/__main__.py @@ -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) diff --git a/mcp_browser/http_gateway.py b/mcp_browser/http_gateway.py new file mode 100644 index 0000000..452e0c9 --- /dev/null +++ b/mcp_browser/http_gateway.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 64c711e..764037f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyyaml>=6.0 jsonpath-ng>=1.5.3 httpx>=0.27 +aiohttp>=3.9 diff --git a/setup.py b/setup.py index 6214372..a6be128 100644 --- a/setup.py +++ b/setup.py @@ -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={ diff --git a/systemd/mcp-browser-proxy.service b/systemd/mcp-browser-proxy.service index 3503f3a..151f4ae 100644 --- a/systemd/mcp-browser-proxy.service +++ b/systemd/mcp-browser-proxy.service @@ -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'