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 .config import ConfigLoader
|
||||||
from .daemon import MCPBrowserDaemon, MCPBrowserClient, get_socket_path, is_daemon_running, kill_daemon_with_children
|
from .daemon import MCPBrowserDaemon, MCPBrowserClient, get_socket_path, is_daemon_running, kill_daemon_with_children
|
||||||
from .logging_config import setup_logging, get_logger
|
from .logging_config import setup_logging, get_logger
|
||||||
|
from .http_gateway import run_streamable_http_gateway
|
||||||
|
|
||||||
|
|
||||||
def build_mcp_request(args) -> Dict[str, Any]:
|
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("--server", "-s", help="Target MCP server name (see --list-servers)")
|
||||||
parser.add_argument("--config", "-c", help="Custom configuration file path")
|
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)")
|
default="interactive", help="Operating mode (default: interactive)")
|
||||||
parser.add_argument("--no-sparse", action="store_true",
|
parser.add_argument("--no-sparse", action="store_true",
|
||||||
help="Disable sparse mode (show all tools)")
|
help="Disable sparse mode (show all tools)")
|
||||||
|
|
@ -711,6 +712,14 @@ Environment:
|
||||||
help="Additional form parameters for OAuth token requests (repeatable)")
|
help="Additional form parameters for OAuth token requests (repeatable)")
|
||||||
parser.add_argument("--oauth-token",
|
parser.add_argument("--oauth-token",
|
||||||
help="Static OAuth bearer token (applies Authorization header)")
|
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
|
# MCP method commands
|
||||||
subparsers = parser.add_subparsers(dest="command", help="MCP methods")
|
subparsers = parser.add_subparsers(dest="command", help="MCP methods")
|
||||||
|
|
@ -875,6 +884,16 @@ Environment:
|
||||||
# Run as daemon
|
# Run as daemon
|
||||||
socket_path = get_socket_path(args.server)
|
socket_path = get_socket_path(args.server)
|
||||||
asyncio.run(run_daemon_mode(browser, socket_path))
|
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:
|
else:
|
||||||
# Interactive mode - can use daemon if available
|
# Interactive mode - can use daemon if available
|
||||||
socket_path = get_socket_path(args.server)
|
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
|
pyyaml>=6.0
|
||||||
jsonpath-ng>=1.5.3
|
jsonpath-ng>=1.5.3
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
|
aiohttp>=3.9
|
||||||
|
|
|
||||||
1
setup.py
1
setup.py
|
|
@ -318,6 +318,7 @@ setup(
|
||||||
"jsonpath-ng>=1.6.0",
|
"jsonpath-ng>=1.6.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"aiohttp>=3.9",
|
||||||
"typing-extensions>=4.0.0;python_version<'3.11'",
|
"typing-extensions>=4.0.0;python_version<'3.11'",
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[Unit]
|
[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
|
Documentation=https://github.com/Xilope0/mcp-browser
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=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_BIN=%h/.local/bin/mcp-browser"
|
||||||
Environment="MCP_BROWSER_CONFIG=%h/.config/mcp-browser/config.yaml"
|
Environment="MCP_BROWSER_CONFIG=%h/.config/mcp-browser/config.yaml"
|
||||||
Environment="MCP_BROWSER_SERVER=default"
|
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_NO_BUILTIN=false"
|
||||||
Environment="MCP_BROWSER_TRANSPORT="
|
Environment="MCP_BROWSER_TRANSPORT="
|
||||||
Environment="MCP_BROWSER_TRANSPORT_URL="
|
Environment="MCP_BROWSER_TRANSPORT_URL="
|
||||||
Environment="MCP_BROWSER_EXTRA_ARGS="
|
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
|
EnvironmentFile=-%h/.config/mcp-browser/browser.env
|
||||||
|
|
||||||
ExecStart=/usr/bin/env bash -lc 'set -eu
|
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=()
|
ARGS=()
|
||||||
if [[ "$MCP_BROWSER_NO_BUILTIN" == "true" ]]; then
|
if [[ "$MCP_BROWSER_NO_BUILTIN" == "true" ]]; then
|
||||||
ARGS+=(--no-builtin)
|
ARGS+=(--no-builtin)
|
||||||
|
|
@ -32,6 +39,10 @@ exec "$MCP_BROWSER_BIN" \
|
||||||
--mode "$MCP_BROWSER_MODE" \
|
--mode "$MCP_BROWSER_MODE" \
|
||||||
--config "$MCP_BROWSER_CONFIG" \
|
--config "$MCP_BROWSER_CONFIG" \
|
||||||
--server "$MCP_BROWSER_SERVER" \
|
--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[@]}" \
|
"${ARGS[@]}" \
|
||||||
$MCP_BROWSER_EXTRA_ARGS'
|
$MCP_BROWSER_EXTRA_ARGS'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue