Compare commits
14 Commits
824a66d7c8
...
546c5ed0a7
| Author | SHA1 | Date |
|---|---|---|
|
|
546c5ed0a7 | |
|
|
0a5c29c1ce | |
|
|
c96cc26939 | |
|
|
bb64267c4d | |
|
|
49963ca58f | |
|
|
1d1dd262c6 | |
|
|
63a3f41726 | |
|
|
7813d32cd0 | |
|
|
341e48a696 | |
|
|
2708a6ff5f | |
|
|
18d94d8913 | |
|
|
4b14d45e28 | |
|
|
6199f28e9e | |
|
|
568fd53ad2 |
|
|
@ -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
|
||||
|
|
@ -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 ngrok’s 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. OpenAI’s MCP interface) with
|
||||
that URL plus any OAuth restrictions you defined.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 68deffefc0a35355ec03d28afd983e9c7717b1e5
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 61399b386c19af9b9c9277727990a6b3c6a95d83
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
pyyaml>=6.0
|
||||
jsonpath-ng>=1.5.3
|
||||
jsonpath-ng>=1.5.3
|
||||
httpx>=0.27
|
||||
aiohttp>=3.9
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
4
setup.py
4
setup.py
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue