Compare commits

...

14 Commits

Author SHA1 Message Date
Andre Heinecke 546c5ed0a7
Fix ngrok invocation
- The OAUTH from ngrok could not be handled by OpenAI and was
only haölf implemented. The policy file is the right way but it still
wont detect it..

- Most importantly we trail the logs at the end of the script to
avoid the script from terminating.
2025-10-11 12:32:03 +02:00
Andre Heinecke 0a5c29c1ce
Initialize earlier
This still does not avoid that we get the error
notifications/initialized is not allowed.
2025-10-11 12:30:39 +02:00
Andre Heinecke c96cc26939
Disable optional builtin servers
Just to reduce problem surface
2025-10-11 12:30:11 +02:00
gpt-5-codex bb64267c4d fix: respect user config in ngrok helper 2025-10-11 07:52:25 +02:00
gpt-5-codex 49963ca58f fix: harden ngrok helper defaults 2025-10-11 07:37:42 +02:00
gpt-5-codex 1d1dd262c6 chore: switch ngrok helper to oauth-based auth 2025-10-11 07:10:01 +02:00
gpt-5-codex 63a3f41726 fix: pass host args to ngrok readiness check 2025-10-11 07:00:08 +02:00
gpt-5-codex 7813d32cd0 feat: add ngrok publishing helper 2025-10-11 06:42:11 +02:00
gpt-5-codex 341e48a696 feat: add built-in streamable-http gateway 2025-10-11 02:19:39 +02:00
gpt-5-codex 2708a6ff5f feat: support legacy streamable-http config entries 2025-10-10 23:55:27 +02:00
gpt-5-codex 18d94d8913 docs: provide hardened nginx and systemd templates 2025-10-10 23:18:46 +02:00
gpt-5-codex 4b14d45e28 fix: fallback to real server when built-ins disabled 2025-10-10 23:15:09 +02:00
gpt-5-codex 6199f28e9e feat: add streamable-http transport support 2025-10-10 22:56:32 +02:00
Andre Heinecke 568fd53ad2
Add python-sdk and modelcontextprocol as doc submodules 2025-10-10 22:27:29 +02:00
19 changed files with 1447 additions and 30 deletions

6
.gitmodules vendored Normal file
View File

@ -0,0 +1,6 @@
[submodule "docs/modelcontextprotocol"]
path = docs/modelcontextprotocol
url = https://github.com/modelcontextprotocol/modelcontextprotocol.git
[submodule "docs/python-sdk"]
path = docs/python-sdk
url = https://github.com/modelcontextprotocol/python-sdk.git

46
docs/NGROK.md Normal file
View File

@ -0,0 +1,46 @@
# Publishing mcp-browser with ngrok
The `scripts/run_mcp_ngrok.sh` helper launches `mcp-browser` in
streamable-http mode and exposes it through an ngrok tunnel. By default it
uses whatever server is marked as the default in your existing config (the same
behaviour as running `mcp-browser` directly). Provide `--config` or `--server`
if you want to publish a different profile.
```bash
./scripts/run_mcp_ngrok.sh \
--allow-origin https://platform.openai.com \
--ngrok-oauth-provider google \
--ngrok-oauth-allow-email you@example.com
```
Key behaviours:
- Picks a free local port and starts `mcp-browser --mode streamable-http` with
`--http-path /mcp`.
- Starts `ngrok http` pointing at that port and prints the public URL once the
tunnel is ready.
- Writes logs to temporary files (paths shown on startup).
- Cleans up both processes when interrupted.
Useful options:
- `--config ~/.claude/mcp-browser/config.yaml` point at your own config file
(often combined with `--server <name>`).
- `--ngrok-region eu` or `--ngrok-domain your-name.ngrok.app` choose a region
or reserved domain.
- `--ngrok-oauth-provider google --ngrok-oauth-allow-email you@example.com`
gate the tunnel behind ngroks OAuth support (recommended when exposing the
gateway).
Additional `mcp-browser` arguments can be passed after `--`, for example to
connect to a streamable HTTP upstream:
```bash
./scripts/run_mcp_ngrok.sh -- \
--transport streamable-http \
--transport-url http://127.0.0.1:12306/mcp
```
The resulting public URL terminates at `/mcp` and is served via HTTPS by ngrok
automatically. Configure your MCP client (e.g. OpenAIs MCP interface) with
that URL plus any OAuth restrictions you defined.

@ -0,0 +1 @@
Subproject commit 68deffefc0a35355ec03d28afd983e9c7717b1e5

1
docs/python-sdk Submodule

@ -0,0 +1 @@
Subproject commit 61399b386c19af9b9c9277727990a6b3c6a95d83

147
examples/mcp.conf Normal file
View File

@ -0,0 +1,147 @@
# Example NGINX front-end for MCP Browser
#
# This template follows the MCP specification (2025-03-26) section 2.4 on dynamic
# client registration:
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#2-4-dynamic-client-registration
#
# Replace the placeholder values (domain, certificate paths, hash slug, upstream
# ports) with values that match your deployment. The configuration provides:
# * OAuth metadata + register/token endpoints backed by an njs helper
# * A protected Streamable HTTP MCP endpoint (auth_request + OAuth)
# * An optional unauthenticated SSE bridge for local debugging
#
# The example assumes that:
# * An njs script lives at /etc/nginx/njs/mcp_oauth.js providing handlers
# for the metadata/register/token endpoints.
# * The mcp-browser bridge listens on 127.0.0.1:8251 for SSE requests.
# * The upstream MCP server (or proxy) exposes streamable-http at
# 127.0.0.1:8250.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
js_path /etc/nginx/njs;
js_import mcp_oauth from /etc/nginx/njs/mcp_oauth.js;
# Upstream pools (tune keepalive as needed)
upstream mcp_streamable_backend {
server 127.0.0.1:8250;
keepalive 32;
}
upstream mcp_browser_backend {
server 127.0.0.1:8251;
keepalive 8;
}
server {
listen 443 ssl http2;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# ssl_trusted_certificate /etc/letsencrypt/live/mcp.example.com/chain.pem;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
# Resource identifiers MUST match what your metadata document returns.
# Use the same hash/digest in mcp_oauth.metadata().
set $mcp_resource "__MCP_RESOURCE_HASH__";
# --- OAuth discovery + token/register endpoints per §2.4 ------------------
location = /.well-known/oauth-authorization-server/$mcp_resource/mcp {
default_type application/json;
js_content mcp_oauth.metadata;
}
location = /.well-known/oauth-protected-resource/$mcp_resource/mcp {
default_type application/json;
js_content mcp_oauth.metadata;
}
location = /.well-known/oauth-authorization-server {
default_type application/json;
js_content mcp_oauth.metadata;
}
location = /.well-known/oauth-protected-resource {
default_type application/json;
js_content mcp_oauth.metadata;
}
location = /register {
default_type application/json;
js_content mcp_oauth.register;
}
location = /token {
default_type application/json;
js_content mcp_oauth.token;
}
# The metadata served above should advertise this /authorize endpoint.
# Update the upstream target to whatever Authorization Server you use.
location = /authorize {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass https://idp.example.com/oauth2/authorize;
}
# Internal auth_request hook used by the protected MCP endpoint.
location = /_oauth_check {
internal;
js_content mcp_oauth.check;
}
# --- Protected Streamable HTTP MCP endpoint --------------------------------
# Requires successful OAuth check before proxying to the upstream server.
location ^~ /$mcp_resource/mcp/ {
auth_request /_oauth_check;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Authorization "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Streaming friendly defaults
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 1d;
proxy_send_timeout 1d;
proxy_connect_timeout 30s;
# Advertise latest protocol version if desired
# proxy_set_header MCP-Protocol-Version "2025-06-18";
proxy_pass http://mcp_streamable_backend;
}
# --- Optional unauthenticated SSE bridge for local debugging ---------------
location ^~ /$mcp_resource/mcp-browser/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_request_buffering off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1d;
proxy_send_timeout 1d;
send_timeout 1d;
keepalive_timeout 1d;
proxy_pass http://mcp_browser_backend/servers/mcp-browser/sse;
}
}

View File

@ -0,0 +1,49 @@
# Minimal NGINX reverse proxy for MCP Browser SSE bridge
#
# This configuration omits OAuth and exposes only the unauthenticated SSE
# endpoint. It is intended for quick local setup (e.g. tunnelling the bridge
# to the OpenAI MCP interface) and should not be used on the public Internet
# without adding authentication.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream mcp_browser_backend {
server 127.0.0.1:8251;
keepalive 4;
}
server {
listen 443 ssl http2;
server_name mcp-dev.example.com;
ssl_certificate /etc/letsencrypt/live/mcp-dev.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp-dev.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
# Replace __MCP_SLUG__ with your private slug value.
location ^~ /__MCP_SLUG__/mcp-browser/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_request_buffering off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1d;
proxy_send_timeout 1d;
send_timeout 1d;
keepalive_timeout 1d;
proxy_pass http://mcp_browser_backend/servers/mcp-browser/sse;
}
}

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]:
@ -326,7 +327,8 @@ async def handle_mcp_command(args):
browser = MCPBrowser(
server_name=args.server,
config_path=config_path,
enable_builtin_servers=not args.no_builtin
enable_builtin_servers=not args.no_builtin,
transport_override=getattr(args, "transport_override", None)
)
try:
@ -409,7 +411,8 @@ async def start_daemon_background(args):
browser = MCPBrowser(
server_name=args.server,
config_path=config_path,
enable_builtin_servers=not args.no_builtin
enable_builtin_servers=not args.no_builtin,
transport_override=getattr(args, "transport_override", None)
)
# Run daemon
@ -659,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)")
@ -687,6 +690,36 @@ Environment:
parser.add_argument("--version", "-v", action="version",
version=f"%(prog)s {__version__}",
help="Show program version and exit")
parser.add_argument("--transport", choices=["stdio", "streamable-http"],
help="Override transport type for the target server")
parser.add_argument("--transport-url",
help="Endpoint URL for streamable-http transport override")
parser.add_argument("--transport-header", action="append", metavar="KEY=VALUE",
help="Additional HTTP headers for streamable-http transport (repeatable)")
parser.add_argument("--transport-timeout", type=float,
help="Override HTTP request timeout in seconds")
parser.add_argument("--transport-sse-timeout", type=float,
help="Override SSE read timeout in seconds")
parser.add_argument("--oauth-token-url",
help="OAuth token endpoint for client credentials flow")
parser.add_argument("--oauth-client-id", help="OAuth client identifier")
parser.add_argument("--oauth-client-secret", help="OAuth client secret")
parser.add_argument("--oauth-scope",
help="OAuth scopes (space-separated)")
parser.add_argument("--oauth-audience",
help="OAuth audience/resource parameter")
parser.add_argument("--oauth-extra-param", action="append", metavar="KEY=VALUE",
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")
@ -725,6 +758,70 @@ Environment:
args = parser.parse_args()
def parse_key_value_pairs(pairs):
result = {}
if not pairs:
return result
for item in pairs:
if "=" not in item:
print(f"Invalid KEY=VALUE format: {item}", file=sys.stderr)
sys.exit(2)
key, value = item.split("=", 1)
key = key.strip()
value = value.strip()
if not key:
print(f"Header key is empty for entry: {item}", file=sys.stderr)
sys.exit(2)
result[key] = value
return result
transport_headers = parse_key_value_pairs(args.transport_header)
oauth_extra = parse_key_value_pairs(args.oauth_extra_param)
if args.oauth_token:
transport_headers.setdefault("Authorization", f"Bearer {args.oauth_token}")
oauth_override = {}
if args.oauth_token_url:
oauth_override["token_url"] = args.oauth_token_url
if args.oauth_client_id:
oauth_override["client_id"] = args.oauth_client_id
if args.oauth_client_secret is not None:
oauth_override["client_secret"] = args.oauth_client_secret
if args.oauth_scope is not None:
oauth_override["scope"] = args.oauth_scope
if args.oauth_audience is not None:
oauth_override["audience"] = args.oauth_audience
if oauth_extra:
oauth_override["extra_params"] = oauth_extra
if args.oauth_token:
oauth_override["access_token"] = args.oauth_token
transport_override = None
if any([
args.transport,
args.transport_url,
transport_headers,
args.transport_timeout is not None,
args.transport_sse_timeout is not None,
oauth_override,
]):
transport_override = {}
if args.transport:
transport_override["type"] = args.transport
if args.transport_url:
transport_override["url"] = args.transport_url
if transport_headers:
transport_override["headers"] = transport_headers
if args.transport_timeout is not None:
transport_override["timeout"] = args.transport_timeout
if args.transport_sse_timeout is not None:
transport_override["sse_timeout"] = args.transport_sse_timeout
if oauth_override:
transport_override["oauth"] = oauth_override
setattr(args, "transport_override", transport_override)
# Handle special commands first
if args.list_servers:
show_available_servers(args.config)
@ -765,7 +862,8 @@ Environment:
browser = MCPBrowser(
server_name=args.server,
config_path=config_path,
enable_builtin_servers=not args.no_builtin
enable_builtin_servers=not args.no_builtin,
transport_override=transport_override
)
# Handle test mode
@ -786,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)
@ -805,4 +913,4 @@ async def async_main(browser: MCPBrowser):
if __name__ == "__main__":
main()
main()

View File

@ -14,15 +14,39 @@ from pathlib import Path
from .default_configs import ConfigManager
@dataclass
class OAuthClientCredentialsConfig:
"""Configuration for OAuth client credentials flow."""
token_url: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
scope: Optional[str] = None
audience: Optional[str] = None
extra_params: Dict[str, str] = field(default_factory=dict)
access_token: Optional[str] = None
@dataclass
class TransportConfig:
"""Transport configuration for connecting to an MCP server."""
type: str = "stdio"
url: Optional[str] = None
headers: Dict[str, str] = field(default_factory=dict)
timeout: float = 30.0
sse_timeout: float = 300.0
oauth: Optional[OAuthClientCredentialsConfig] = None
@dataclass
class MCPServerConfig:
"""Configuration for a single MCP server."""
command: List[str]
command: Optional[List[str]] = None
args: List[str] = field(default_factory=list)
env: Dict[str, str] = field(default_factory=dict)
name: Optional[str] = None
description: Optional[str] = None
enabled: bool = True
transport: TransportConfig = field(default_factory=TransportConfig)
@dataclass
@ -84,13 +108,83 @@ class ConfigLoader:
# Convert to dataclass instances
servers = {}
for name, server_config in config_data.get("servers", {}).items():
transport_data = server_config.get("transport", {}) or {}
# Merge transport metadata from legacy locations
raw_transport_type = (
transport_data.get("type")
or server_config.get("transportType")
or server_config.get("type")
)
normalized_type = "stdio"
if isinstance(raw_transport_type, str):
lowered = raw_transport_type.replace("_", "-").lower()
if lowered in {"streamable-http", "streamablehttp"}:
normalized_type = "streamable-http"
elif lowered in {"stdio", "standard"}:
normalized_type = "stdio"
else:
normalized_type = lowered
oauth_data = transport_data.get("oauth") or {}
oauth_config = None
if oauth_data:
oauth_config = OAuthClientCredentialsConfig(
token_url=oauth_data.get("token_url") or oauth_data.get("tokenUrl"),
client_id=oauth_data.get("client_id") or oauth_data.get("clientId"),
client_secret=oauth_data.get("client_secret") or oauth_data.get("clientSecret"),
scope=oauth_data.get("scope"),
audience=oauth_data.get("audience") or oauth_data.get("resource"),
extra_params=oauth_data.get("extra_params") or oauth_data.get("extraParams", {}),
access_token=oauth_data.get("access_token") or oauth_data.get("accessToken"),
)
transport_url = transport_data.get("url") or server_config.get("url")
headers = transport_data.get("headers") or server_config.get("headers") or {}
timeout = (
transport_data.get("timeout")
or server_config.get("timeout")
or 30.0
)
sse_timeout = (
transport_data.get("sse_timeout")
or transport_data.get("sseTimeout")
or server_config.get("sse_timeout")
or server_config.get("sseTimeout")
or 300.0
)
# Legacy args: ["url", "http://..."] or ["--url=http://..."]
args = server_config.get("args", [])
if not transport_url and isinstance(args, list):
for idx, arg in enumerate(args):
if not isinstance(arg, str):
continue
stripped = arg.strip()
if stripped in {"url", "--url", "--transport-url"}:
if idx + 1 < len(args):
transport_url = args[idx + 1]
break
elif stripped.startswith(("url=", "--url=", "--transport-url=")):
transport_url = stripped.split("=", 1)[1]
break
transport_config = TransportConfig(
type=normalized_type,
url=transport_url,
headers=headers,
timeout=timeout,
sse_timeout=sse_timeout,
oauth=oauth_config
)
servers[name] = MCPServerConfig(
command=server_config["command"],
command=server_config.get("command"),
args=server_config.get("args", []),
env=server_config.get("env", {}),
name=server_config.get("name", name),
description=server_config.get("description"),
enabled=server_config.get("enabled", True)
enabled=server_config.get("enabled", True),
transport=transport_config
)
self._config = MCPBrowserConfig(
@ -111,4 +205,4 @@ class ConfigLoader:
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
self._merge_configs(base[key], value)
else:
base[key] = value
base[key] = value

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

@ -42,12 +42,14 @@ class MultiServerManager:
"builtin:memory": MCPServerConfig(
command=["python3", str(base_path / "memory" / "memory_server.py")],
name="memory",
description="Persistent memory and context management"
description="Persistent memory and context management",
enabled=False # Disabled by default, tmux is preferred
),
"builtin:patterns": MCPServerConfig(
command=["python3", str(base_path / "pattern_manager" / "pattern_server.py")],
name="patterns",
description="Auto-response pattern management"
description="Auto-response pattern management",
enabled=False # Disabled by default, tmux is preferred
),
"builtin:onboarding": MCPServerConfig(
command=["python3", str(base_path / "onboarding" / "onboarding_server.py")],
@ -169,4 +171,4 @@ class MultiServerManager:
except Exception as e:
self.logger.error(f"Error stopping server {name}: {e}")
self.servers.clear()
self.servers.clear()

View File

@ -10,7 +10,12 @@ import asyncio
from typing import Dict, Any, Optional, Union
from pathlib import Path
from .config import ConfigLoader, MCPBrowserConfig
from .config import (
ConfigLoader,
MCPBrowserConfig,
MCPServerConfig,
OAuthClientCredentialsConfig,
)
from .server import MCPServer
from .multi_server import MultiServerManager
from .registry import ToolRegistry
@ -29,7 +34,7 @@ class MCPBrowser:
"""
def __init__(self, config_path: Optional[Path] = None, server_name: Optional[str] = None,
enable_builtin_servers: bool = True):
enable_builtin_servers: bool = True, transport_override: Optional[Dict[str, Any]] = None):
"""
Initialize MCP Browser.
@ -47,6 +52,7 @@ class MCPBrowser:
self.virtual_handler: Optional[VirtualToolHandler] = None
self._server_name = server_name
self._enable_builtin_servers = enable_builtin_servers
self._transport_override = transport_override
self._initialized = False
self._response_buffer: Dict[Union[str, int], asyncio.Future] = {}
self._next_id = 1
@ -54,6 +60,42 @@ class MCPBrowser:
self._config_watcher = None
self._server_configs = {}
self._config_mtime = None
def _apply_transport_override(self, server_config: MCPServerConfig):
"""Apply transport overrides from CLI arguments."""
override = self._transport_override or {}
transport = server_config.transport
transport.type = override.get("type", transport.type)
if override.get("url"):
transport.url = override["url"]
if override.get("headers"):
transport.headers.update(override["headers"])
if "timeout" in override and override["timeout"] is not None:
transport.timeout = override["timeout"]
if "sse_timeout" in override and override["sse_timeout"] is not None:
transport.sse_timeout = override["sse_timeout"]
oauth_override = override.get("oauth")
if oauth_override:
if not transport.oauth:
transport.oauth = OAuthClientCredentialsConfig()
oauth = transport.oauth
if oauth_override.get("token_url"):
oauth.token_url = oauth_override["token_url"]
if oauth_override.get("client_id"):
oauth.client_id = oauth_override["client_id"]
if "client_secret" in oauth_override:
oauth.client_secret = oauth_override["client_secret"]
if "scope" in oauth_override:
oauth.scope = oauth_override["scope"]
if "audience" in oauth_override:
oauth.audience = oauth_override["audience"]
if "extra_params" in oauth_override and oauth_override["extra_params"] is not None:
oauth.extra_params.update(oauth_override["extra_params"])
if "access_token" in oauth_override:
oauth.access_token = oauth_override["access_token"]
async def __aenter__(self):
"""Async context manager entry."""
@ -74,10 +116,33 @@ class MCPBrowser:
# Determine which server to use
server_name = self._server_name or self.config.default_server
if not server_name or server_name not in self.config.servers:
if not server_name:
raise ValueError("No default MCP server configured")
if server_name not in self.config.servers:
raise ValueError(f"Server '{server_name}' not found in configuration")
# If built-ins are disabled but the selected server is builtin-only,
# fall back to the first enabled non-builtin server if available.
if (not self._enable_builtin_servers) and server_name == "builtin-only":
fallback_name = next(
(
name for name, cfg in self.config.servers.items()
if name != "builtin-only" and cfg.enabled
),
None
)
if fallback_name:
self.logger.info(
"Built-in servers disabled; falling back to '%s' instead of 'builtin-only'",
fallback_name,
)
server_name = fallback_name
self._server_name = fallback_name
server_config = self.config.servers[server_name]
if self._transport_override:
self._apply_transport_override(server_config)
# Create multi-server manager if using built-in servers
if self._enable_builtin_servers:
@ -173,7 +238,11 @@ class MCPBrowser:
# Initialize if needed for other requests
if not self._initialized:
await self.initialize()
# After initalization we MUST return notfications iniitialized
return {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
# Check if this is a virtual tool call
if jsonrpc_object.get("method") == "tools/call":
tool_name = jsonrpc_object.get("params", {}).get("name")
@ -374,12 +443,16 @@ class MCPBrowser:
if self.server:
tools_response = await self.server.send_request("tools/list", {})
if "error" in tools_response:
raise RuntimeError(f"Failed to list tools: {tools_response['error']}")
# Update registry with tools
if "result" in tools_response and "tools" in tools_response["result"]:
self.registry.update_tools(tools_response["result"]["tools"])
# Normalize response structure
tools_data = None
if isinstance(tools_response, dict):
if "tools" in tools_response:
tools_data = tools_response["tools"]
elif "result" in tools_response and isinstance(tools_response["result"], dict):
tools_data = tools_response["result"].get("tools")
if tools_data:
self.registry.update_tools(tools_data)
# Also get tools from multi-server if enabled
if self.multi_server:
@ -387,7 +460,18 @@ class MCPBrowser:
# Add to registry without going through filter
existing_tools = self.registry.raw_tool_list
self.registry.update_tools(existing_tools + builtin_tools)
# Send initialize request directly to server
init_response = await self.server.send_request("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "mcp-browser",
"version": "0.1.0"
}
})
def _handle_server_message(self, message: dict):
"""Handle incoming message from MCP server."""
# Apply incoming filter
@ -469,4 +553,4 @@ async def create_browser(config_path: Optional[Path] = None,
"""Create and initialize an MCP Browser instance."""
browser = MCPBrowser(config_path, server_name)
await browser.initialize()
return browser
return browser

View File

@ -9,12 +9,11 @@ import os
import json
import asyncio
import subprocess
from typing import Optional, Dict, Any, Callable, List
from pathlib import Path
from typing import Optional, Dict, Any, Callable, List, Union
from .buffer import JsonRpcBuffer
from .config import MCPServerConfig
from .logging_config import get_logger, TRACE
from .streamable_http import StreamableHTTPClient, StreamableHTTPError
import logging
@ -32,9 +31,30 @@ class MCPServer:
self._pending_requests: Dict[Union[str, int], asyncio.Future] = {}
self._last_error_time: Optional[float] = None
self._offline_since: Optional[float] = None
self._http_client: Optional[StreamableHTTPClient] = None
async def start(self):
"""Start the MCP server process."""
if self.config.transport.type == "streamable-http":
if self._http_client:
return
if not self.config.transport.url:
raise ValueError("Streamable HTTP transport requires 'url' option")
self.logger.info(f"Connecting to streamable HTTP endpoint: {self.config.transport.url}")
self._http_client = StreamableHTTPClient(
self.config.transport.url,
headers=self.config.transport.headers,
timeout=self.config.transport.timeout,
sse_timeout=self.config.transport.sse_timeout,
oauth_config=self.config.transport.oauth,
logger=self.logger,
)
await self._http_client.start()
self._running = True
self._offline_since = None
return
if self.process:
return
@ -46,6 +66,9 @@ class MCPServer:
self.logger.warning(f"Server has been offline for {offline_duration:.0f}s, skipping start")
raise RuntimeError(f"Server marked as offline since {offline_duration:.0f}s ago")
if not self.config.command:
raise ValueError("Server command not configured for stdio transport")
# Prepare environment
env = os.environ.copy()
env.update({
@ -85,6 +108,13 @@ class MCPServer:
async def stop(self):
"""Stop the MCP server process."""
if self.config.transport.type == "streamable-http":
self._running = False
if self._http_client:
await self._http_client.stop()
self._http_client = None
return
self._running = False
if self.process:
@ -116,6 +146,9 @@ class MCPServer:
Returns:
Response result or raises exception on error
"""
if self.config.transport.type == "streamable-http":
return await self._send_request_http(method, params or {})
if not self.process:
raise RuntimeError("MCP server not started")
@ -151,6 +184,41 @@ class MCPServer:
self.logger.error(f"Timeout waiting for response to {method} (timeout={timeout}s)")
self._mark_offline()
raise TimeoutError(f"No response for request {request_id}")
async def _send_request_http(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""Send request via streamable HTTP transport."""
if not self._http_client:
raise RuntimeError("Streamable HTTP client not started")
request_id = self._next_id
self._next_id += 1
request = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params
}
loop = asyncio.get_running_loop()
future: asyncio.Future = loop.create_future()
self._pending_requests[request_id] = future
timeout = self.config.transport.timeout or 30.0
try:
await self._http_client.send(request, self._handle_message)
result = await asyncio.wait_for(future, timeout=timeout)
return result
except asyncio.TimeoutError:
raise TimeoutError(f"No response for request {request_id}")
except StreamableHTTPError:
raise
except Exception as exc:
self.logger.error(f"Streamable HTTP request failed: {exc}")
raise
finally:
self._pending_requests.pop(request_id, None)
def send_raw(self, message: str):
"""Send raw message to MCP server (for pass-through)."""
@ -219,4 +287,4 @@ class MCPServer:
try:
handler(message)
except Exception as e:
self.logger.error(f"Handler error: {e}")
self.logger.error(f"Handler error: {e}")

View File

@ -0,0 +1,289 @@
"""
Minimal Streamable HTTP transport support for MCP Browser.
Provides a lightweight client that can communicate with MCP servers using
the streamable-http transport defined by the MCP specification.
"""
from __future__ import annotations
import asyncio
import json
import time
from typing import Any, Awaitable, Callable, Dict, Optional
import httpx
from .config import OAuthClientCredentialsConfig
from .logging_config import get_logger
MCP_SESSION_HEADER = "mcp-session-id"
MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
LAST_EVENT_ID_HEADER = "last-event-id"
class StreamableHTTPError(Exception):
"""Base error for streamable HTTP transport failures."""
class StreamableHTTPClient:
"""Minimal client for the MCP streamable-http transport."""
def __init__(
self,
url: str,
*,
headers: Optional[Dict[str, str]] = None,
timeout: float = 30.0,
sse_timeout: float = 300.0,
oauth_config: Optional[OAuthClientCredentialsConfig] = None,
logger=None,
) -> None:
self.url = url
self.headers = headers.copy() if headers else {}
self.timeout = timeout
self.sse_timeout = sse_timeout
self.oauth_config = oauth_config
self.logger = logger or get_logger(__name__)
self._client: Optional[httpx.AsyncClient] = None
self._session_id: Optional[str] = None
self._protocol_version: Optional[str] = None
self._last_event_id: Optional[str] = None
self._token: Optional[str] = oauth_config.access_token if oauth_config else None
self._token_expires_at: Optional[float] = None
self._token_lock = asyncio.Lock()
async def start(self) -> None:
"""Initialise underlying HTTP client."""
if self._client is not None:
return
self._client = httpx.AsyncClient(timeout=self.timeout)
if self.oauth_config and self.oauth_config.token_url:
await self._ensure_token()
async def stop(self) -> None:
"""Shutdown HTTP client."""
if self._client:
await self._client.aclose()
self._client = None
async def send(
self,
request: Dict[str, Any],
message_callback: Callable[[Dict[str, Any]], Awaitable[None]],
) -> None:
"""Send a JSON-RPC request and stream responses to callback."""
if not self._client:
raise StreamableHTTPError("Streamable HTTP client not started")
await self._ensure_token()
headers = self._build_headers()
try:
response = await self._client.post(self.url, json=request, headers=headers)
except httpx.HTTPError as exc:
raise StreamableHTTPError(f"Streamable HTTP request failed: {exc}") from exc
self._store_session_headers(response)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
body = await self._safe_read_text(response)
message = body or exc.response.text
raise StreamableHTTPError(f"HTTP {exc.response.status_code}: {message}") from exc
content_type = response.headers.get("content-type", "")
if "text/event-stream" in content_type:
await self._consume_sse(response, message_callback)
else:
await self._consume_json(response, message_callback)
def _build_headers(self) -> Dict[str, str]:
headers = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
**self.headers,
}
if self._session_id and MCP_SESSION_HEADER not in headers:
headers[MCP_SESSION_HEADER] = self._session_id
if self._protocol_version and MCP_PROTOCOL_VERSION_HEADER not in headers:
headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version
if self._last_event_id and LAST_EVENT_ID_HEADER not in headers:
headers[LAST_EVENT_ID_HEADER] = self._last_event_id
if self._token and "authorization" not in {k.lower(): v for k, v in headers.items()}:
headers["Authorization"] = f"Bearer {self._token}"
return headers
async def _ensure_token(self) -> None:
if not self.oauth_config:
return
if self.oauth_config.access_token and not self.oauth_config.token_url:
self._token = self.oauth_config.access_token
return
if not self.oauth_config.token_url or not self.oauth_config.client_id:
return
async with self._token_lock:
if self._token and self._token_expires_at:
if time.time() < self._token_expires_at - 30:
return
await self._refresh_token()
async def _refresh_token(self) -> None:
assert self._client is not None
data = {
"grant_type": "client_credentials",
}
if self.oauth_config.scope:
data["scope"] = self.oauth_config.scope
if self.oauth_config.audience:
data["audience"] = self.oauth_config.audience
if self.oauth_config.extra_params:
data.update(self.oauth_config.extra_params)
auth = None
if self.oauth_config.client_secret:
auth = (self.oauth_config.client_id or "", self.oauth_config.client_secret)
else:
data["client_id"] = self.oauth_config.client_id
response = await self._client.post(
self.oauth_config.token_url, # type: ignore[arg-type]
data=data,
headers={"content-type": "application/x-www-form-urlencoded"},
auth=auth,
)
try:
response.raise_for_status()
except httpx.HTTPError as exc:
body = await self._safe_read_text(response)
raise StreamableHTTPError(
f"Failed to refresh OAuth token: {body or response.text}"
) from exc
token_data = response.json()
access_token = token_data.get("access_token")
if not access_token:
raise StreamableHTTPError("OAuth token response missing access_token")
self._token = access_token
expires_in = token_data.get("expires_in")
if expires_in:
try:
self._token_expires_at = time.time() + float(expires_in)
except (TypeError, ValueError):
self._token_expires_at = None
else:
self._token_expires_at = None
async def _consume_json(
self,
response: httpx.Response,
message_callback: Callable[[Dict[str, Any]], Awaitable[None]],
) -> None:
text = await response.aread()
if not text:
return
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
raise StreamableHTTPError(f"Invalid JSON response: {exc}") from exc
messages = payload if isinstance(payload, list) else [payload]
for message in messages:
if isinstance(message, dict):
self._update_protocol_version(message)
await message_callback(message)
async def _consume_sse(
self,
response: httpx.Response,
message_callback: Callable[[Dict[str, Any]], Awaitable[None]],
) -> None:
event: Dict[str, Any] = {"data": []}
async for line in response.aiter_lines():
if line == "":
await self._flush_event(event, message_callback)
event = {"data": []}
continue
if line.startswith(":"):
continue
field, _, raw_value = line.partition(":")
value = raw_value.lstrip(" ")
if field == "data":
event.setdefault("data", []).append(value)
elif field == "event":
event["event"] = value
elif field == "id":
event["id"] = value
elif field == "retry":
continue
await self._flush_event(event, message_callback)
async def _flush_event(
self,
event: Dict[str, Any],
message_callback: Callable[[Dict[str, Any]], Awaitable[None]],
) -> None:
data_lines = event.get("data") or []
if not data_lines:
return
data = "\n".join(data_lines)
try:
message = json.loads(data)
except json.JSONDecodeError:
self.logger.warning("Failed to parse SSE event payload")
return
if not isinstance(message, dict):
return
if event.get("id"):
self._last_event_id = event["id"]
self._update_protocol_version(message)
await message_callback(message)
def _update_protocol_version(self, message: Dict[str, Any]) -> None:
result = message.get("result")
if isinstance(result, dict) and result.get("protocolVersion"):
self._protocol_version = str(result["protocolVersion"])
def _store_session_headers(self, response: httpx.Response) -> None:
session_id = response.headers.get(MCP_SESSION_HEADER)
if session_id:
self._session_id = session_id
async def _safe_read_text(self, response: httpx.Response) -> Optional[str]:
try:
raw = await response.aread()
except Exception:
return None
if isinstance(raw, str):
return raw
try:
charset = response.charset or "utf-8"
except Exception:
charset = "utf-8"
try:
return raw.decode(charset)
except Exception:
try:
return raw.decode("utf-8", errors="replace")
except Exception:
return None

View File

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

9
scripts/oauth_policy.yml Normal file
View File

@ -0,0 +1,9 @@
on_http_request:
- actions:
- type: oauth
config:
provider: google
- expressions:
- "!(actions.ngrok.oauth.identity.email in ['andre.heinecke@gmail.com'])"
actions:
- type: deny

229
scripts/run_mcp_ngrok.sh Executable file
View File

@ -0,0 +1,229 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: run_mcp_ngrok.sh [options] [-- additional mcp-browser args]
Launch mcp-browser in streamable-http mode and expose it via an ngrok HTTPS
endpoint. By default the script uses a minimal temporary configuration that
only exposes the built-in tools; pass --config/--server if you want something
else.
Options:
--config PATH Path to mcp-browser config file.
--server NAME Server entry to use (optional).
--http-host HOST Local interface for the gateway (default: 127.0.0.1).
--http-port PORT Local port for the gateway (default: auto).
--http-path PATH HTTP path prefix (default: /mcp).
--allow-origin ORIGIN Value for Access-Control-Allow-Origin header
(default: https://platform.openai.com).
--ngrok-domain DOMAIN Reserved ngrok domain to use (optional).
--ngrok-region REGION ngrok region code (optional).
--ngrok-oauth-policy-file PROVIDER Enable ngrok OAuth (e.g. google, github).
--ngrok-inspect true|false Enable ngrok inspector (default: false).
--mcp-arg ARG Extra argument passed to mcp-browser (repeatable).
--ngrok-arg ARG Extra argument passed to ngrok (repeatable).
-h, --help Show this help message.
Environment overrides:
MCP_BROWSER_BIN (default: mcp-browser)
NGROK_BIN (default: ngrok)
The script keeps both processes running and forwards termination signals.
USAGE
}
MCP_BIN=${MCP_BROWSER_BIN:-mcp-browser}
NGROK_BIN=${NGROK_BIN:-ngrok}
CONFIG_PATH=""
SERVER_NAME=""
HTTP_HOST="127.0.0.1"
HTTP_PORT=""
HTTP_PATH="/mcp"
ALLOW_ORIGIN="https://platform.openai.com"
NGROK_DOMAIN=""
NGROK_REGION=""
NGROK_OAUTH_POLICY_FILE=""
#""$(dirname $0)/oauth_policy.yml"
NGROK_INSPECT="true"
MCP_EXTRA_ARGS=()
NGROK_EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--config)
CONFIG_PATH=$2; shift 2;;
--server)
SERVER_NAME=$2; shift 2;;
--http-host)
HTTP_HOST=$2; shift 2;;
--http-port)
HTTP_PORT=$2; shift 2;;
--http-path)
HTTP_PATH=$2; shift 2;;
--allow-origin)
ALLOW_ORIGIN=$2; shift 2;;
--ngrok-domain)
NGROK_DOMAIN=$2; shift 2;;
--ngrok-region)
NGROK_REGION=$2; shift 2;;
--ngrok-oauth-policy-file)
NGROK_OAUTH_POLICY_FILE=$2; shift 2;;
--ngrok-inspect)
NGROK_INSPECT=$2; shift 2;;
--mcp-arg)
MCP_EXTRA_ARGS+=("$2"); shift 2;;
--ngrok-arg)
NGROK_EXTRA_ARGS+=("$2"); shift 2;;
-h|--help)
usage; exit 0;;
--)
shift
if [[ $# -gt 0 ]]; then
for arg in "$@"; do
MCP_EXTRA_ARGS+=("$arg")
done
fi
break;;
*)
echo "Unknown option: $1" >&2
usage
exit 1;;
esac
done
if ! command -v "$MCP_BIN" >/dev/null 2>&1; then
echo "Error: mcp-browser binary '$MCP_BIN' not found" >&2
exit 1
fi
if ! command -v "$NGROK_BIN" >/dev/null 2>&1; then
echo "Error: ngrok binary '$NGROK_BIN' not found" >&2
exit 1
fi
if [[ -z "$HTTP_PORT" ]]; then
if ! HTTP_PORT=$(python3 - <<'PY'
import socket
try:
s = socket.socket()
s.bind(('127.0.0.1', 0))
port = s.getsockname()[1]
s.close()
print(port)
except Exception:
raise SystemExit(1)
PY
); then
HTTP_PORT=39129
fi
fi
HTTP_PATH="/${HTTP_PATH#/}"
MCP_CMD=("$MCP_BIN" --mode streamable-http \
--http-host "$HTTP_HOST" --http-port "$HTTP_PORT" \
--http-path "$HTTP_PATH" --http-allow-origin "$ALLOW_ORIGIN")
if [[ -n "$SERVER_NAME" ]]; then
MCP_CMD+=(--server "$SERVER_NAME")
fi
if [[ -n "$CONFIG_PATH" ]]; then
MCP_CMD+=(--config "$CONFIG_PATH")
fi
[[ ${#MCP_EXTRA_ARGS[@]} -gt 0 ]] && MCP_CMD+=("${MCP_EXTRA_ARGS[@]}")
MCP_LOG=$(mktemp -t mcp-browser-gateway.XXXXXX.log)
NGROK_LOG=$(mktemp -t ngrok-mcp.XXXXXX.log)
cleanup() {
[[ -n "${NGROK_PID:-}" ]] && kill "$NGROK_PID" >/dev/null 2>&1 || true
[[ -n "${MCP_PID:-}" ]] && kill "$MCP_PID" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
"${MCP_CMD[@]}" >"$MCP_LOG" 2>&1 &
MCP_PID=$!
ready=0
for _ in {1..60}; do
if ! kill -0 "$MCP_PID" >/dev/null 2>&1; then
echo "mcp-browser exited early. Recent log output:" >&2
tail -n 40 "$MCP_LOG" >&2 || true
wait "$MCP_PID" >/dev/null 2>&1 || true
exit 1
fi
if grep -q "Streamable HTTP gateway listening" "$MCP_LOG"; then
ready=1
break
fi
sleep 0.5
done
if [[ $ready -ne 1 ]]; then
echo "Gateway did not become ready within timeout. Recent log output:" >&2
tail -n 40 "$MCP_LOG" >&2 || true
exit 1
fi
NGROK_CMD=("$NGROK_BIN" http "http://$HTTP_HOST:$HTTP_PORT")
NGROK_CMD+=(--request-header-add "X-MCP-Gateway:true")
NGROK_CMD+=(--request-header-add "ngrok-skip-browser-warning:1")
NGROK_CMD+=(--response-header-add "Cache-Control:no-store")
NGROK_CMD+=(--inspect="$NGROK_INSPECT")
if [[ -n "$NGROK_DOMAIN" ]]; then
NGROK_CMD+=(--domain "$NGROK_DOMAIN")
fi
if [[ -n "$NGROK_REGION" ]]; then
NGROK_CMD+=(--region "$NGROK_REGION")
fi
if [[ -n "$NGROK_OAUTH_POLICY_FILE" ]]; then
NGROK_CMD+=(--traffic-policy-file=$NGROK_OAUTH_POLICY_FILE)
fi
[[ ${#NGROK_EXTRA_ARGS[@]} -gt 0 ]] && NGROK_CMD+=("${NGROK_EXTRA_ARGS[@]}")
"${NGROK_CMD[@]}" >"$NGROK_LOG" 2>&1 &
NGROK_PID=$!
# Wait for ngrok API to report tunnel URL
PUBLIC_URL=""
for _ in {1..60}; do
sleep 0.5
PUBLIC_URL=$(python3 - <<'PY'
import json
import sys
import urllib.request
try:
with urllib.request.urlopen('http://127.0.0.1:4040/api/tunnels') as resp:
data = json.load(resp)
except Exception:
sys.exit(1)
for tunnel in data.get('tunnels', []):
url = tunnel.get('public_url')
if url:
print(url)
sys.exit(0)
sys.exit(1)
PY
) && break
PUBLIC_URL=""
done
if [[ -z "$PUBLIC_URL" ]]; then
echo "ngrok tunnel did not start. See $NGROK_LOG" >&2
exit 1
fi
cat <<EOF
mcp-browser gateway running on http://$HTTP_HOST:$HTTP_PORT$HTTP_PATH
ngrok tunnel available at: $PUBLIC_URL$HTTP_PATH
Local logs:
mcp-browser: $MCP_LOG
ngrok: $NGROK_LOG
Press Ctrl+C to stop.
EOF
echo "Showing log files"
tail -f $MCP_LOG $NGROK_LOG

View File

@ -317,6 +317,8 @@ setup(
"aiofiles>=23.0.0",
"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={
@ -359,4 +361,4 @@ setup(
"Programming Language :: Python :: 3.12",
],
keywords="mcp model-context-protocol ai llm tools json-rpc",
)
)

View File

@ -0,0 +1,66 @@
[Unit]
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
[Service]
Type=simple
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=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)
fi
if [[ -n "$MCP_BROWSER_TRANSPORT" ]]; then
ARGS+=(--transport "$MCP_BROWSER_TRANSPORT")
fi
if [[ -n "$MCP_BROWSER_TRANSPORT_URL" ]]; then
ARGS+=(--transport-url "$MCP_BROWSER_TRANSPORT_URL")
fi
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'
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
ProtectControlGroups=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=default.target

View File

@ -0,0 +1,48 @@
[Unit]
Description=Maintain SSH reverse tunnel for MCP endpoints
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment="MCP_TUNNEL_HOST=mcp.example.com"
Environment="MCP_TUNNEL_USER=mcp"
Environment="MCP_TUNNEL_KEY=%h/.ssh/mcp_reverse"
Environment="MCP_REMOTE_STREAMABLE_PORT=8250"
Environment="MCP_LOCAL_STREAMABLE_PORT=14000"
Environment="MCP_REMOTE_BROWSER_PORT=8251"
Environment="MCP_LOCAL_BROWSER_PORT=14001"
Environment="MCP_TUNNEL_EXTRA_ARGS="
EnvironmentFile=-%h/.config/mcp-browser/tunnel.env
ExecStart=/usr/bin/env sh -c '\
set -eu; \
exec /usr/bin/ssh -F /dev/null -i "$MCP_TUNNEL_KEY" -NT \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=3 \
-o StreamLocalBindUnlink=yes \
-R 127.0.0.1:"$MCP_REMOTE_STREAMABLE_PORT":127.0.0.1:"$MCP_LOCAL_STREAMABLE_PORT" \
-R 127.0.0.1:"$MCP_REMOTE_BROWSER_PORT":127.0.0.1:"$MCP_LOCAL_BROWSER_PORT" \
$MCP_TUNNEL_EXTRA_ARGS \
"$MCP_TUNNEL_USER@$MCP_TUNNEL_HOST"'
Restart=always
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
ProtectControlGroups=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadOnlyPaths=%h/.ssh
[Install]
WantedBy=default.target