feat: add built-in streamable-http gateway
This commit is contained in:
parent
2708a6ff5f
commit
341e48a696
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pyyaml>=6.0
|
||||
jsonpath-ng>=1.5.3
|
||||
httpx>=0.27
|
||||
aiohttp>=3.9
|
||||
|
|
|
|||
1
setup.py
1
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={
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue