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 .config import ConfigLoader
|
||||||
from .daemon import MCPBrowserDaemon, MCPBrowserClient, get_socket_path, is_daemon_running, kill_daemon_with_children
|
from .daemon import MCPBrowserDaemon, MCPBrowserClient, get_socket_path, is_daemon_running, kill_daemon_with_children
|
||||||
from .logging_config import setup_logging, get_logger
|
from .logging_config import setup_logging, get_logger
|
||||||
|
from .http_gateway import run_streamable_http_gateway
|
||||||
|
|
||||||
|
|
||||||
def build_mcp_request(args) -> Dict[str, Any]:
|
def build_mcp_request(args) -> Dict[str, Any]:
|
||||||
|
|
@ -326,7 +327,8 @@ async def handle_mcp_command(args):
|
||||||
browser = MCPBrowser(
|
browser = MCPBrowser(
|
||||||
server_name=args.server,
|
server_name=args.server,
|
||||||
config_path=config_path,
|
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:
|
try:
|
||||||
|
|
@ -409,7 +411,8 @@ async def start_daemon_background(args):
|
||||||
browser = MCPBrowser(
|
browser = MCPBrowser(
|
||||||
server_name=args.server,
|
server_name=args.server,
|
||||||
config_path=config_path,
|
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
|
# Run daemon
|
||||||
|
|
@ -659,7 +662,7 @@ Environment:
|
||||||
)
|
)
|
||||||
parser.add_argument("--server", "-s", help="Target MCP server name (see --list-servers)")
|
parser.add_argument("--server", "-s", help="Target MCP server name (see --list-servers)")
|
||||||
parser.add_argument("--config", "-c", help="Custom configuration file path")
|
parser.add_argument("--config", "-c", help="Custom configuration file path")
|
||||||
parser.add_argument("--mode", choices=["interactive", "server", "daemon"],
|
parser.add_argument("--mode", choices=["interactive", "server", "daemon", "streamable-http"],
|
||||||
default="interactive", help="Operating mode (default: interactive)")
|
default="interactive", help="Operating mode (default: interactive)")
|
||||||
parser.add_argument("--no-sparse", action="store_true",
|
parser.add_argument("--no-sparse", action="store_true",
|
||||||
help="Disable sparse mode (show all tools)")
|
help="Disable sparse mode (show all tools)")
|
||||||
|
|
@ -687,6 +690,36 @@ Environment:
|
||||||
parser.add_argument("--version", "-v", action="version",
|
parser.add_argument("--version", "-v", action="version",
|
||||||
version=f"%(prog)s {__version__}",
|
version=f"%(prog)s {__version__}",
|
||||||
help="Show program version and exit")
|
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
|
# MCP method commands
|
||||||
subparsers = parser.add_subparsers(dest="command", help="MCP methods")
|
subparsers = parser.add_subparsers(dest="command", help="MCP methods")
|
||||||
|
|
@ -725,6 +758,70 @@ Environment:
|
||||||
|
|
||||||
args = parser.parse_args()
|
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
|
# Handle special commands first
|
||||||
if args.list_servers:
|
if args.list_servers:
|
||||||
show_available_servers(args.config)
|
show_available_servers(args.config)
|
||||||
|
|
@ -765,7 +862,8 @@ Environment:
|
||||||
browser = MCPBrowser(
|
browser = MCPBrowser(
|
||||||
server_name=args.server,
|
server_name=args.server,
|
||||||
config_path=config_path,
|
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
|
# Handle test mode
|
||||||
|
|
@ -786,6 +884,16 @@ Environment:
|
||||||
# Run as daemon
|
# Run as daemon
|
||||||
socket_path = get_socket_path(args.server)
|
socket_path = get_socket_path(args.server)
|
||||||
asyncio.run(run_daemon_mode(browser, socket_path))
|
asyncio.run(run_daemon_mode(browser, socket_path))
|
||||||
|
elif args.mode == "streamable-http":
|
||||||
|
asyncio.run(
|
||||||
|
run_streamable_http_gateway(
|
||||||
|
browser,
|
||||||
|
host=args.http_host,
|
||||||
|
port=args.http_port,
|
||||||
|
path=args.http_path,
|
||||||
|
allow_origin=args.http_allow_origin,
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Interactive mode - can use daemon if available
|
# Interactive mode - can use daemon if available
|
||||||
socket_path = get_socket_path(args.server)
|
socket_path = get_socket_path(args.server)
|
||||||
|
|
@ -805,4 +913,4 @@ async def async_main(browser: MCPBrowser):
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,39 @@ from pathlib import Path
|
||||||
from .default_configs import ConfigManager
|
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
|
@dataclass
|
||||||
class MCPServerConfig:
|
class MCPServerConfig:
|
||||||
"""Configuration for a single MCP server."""
|
"""Configuration for a single MCP server."""
|
||||||
command: List[str]
|
command: Optional[List[str]] = None
|
||||||
args: List[str] = field(default_factory=list)
|
args: List[str] = field(default_factory=list)
|
||||||
env: Dict[str, str] = field(default_factory=dict)
|
env: Dict[str, str] = field(default_factory=dict)
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
transport: TransportConfig = field(default_factory=TransportConfig)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -84,13 +108,83 @@ class ConfigLoader:
|
||||||
# Convert to dataclass instances
|
# Convert to dataclass instances
|
||||||
servers = {}
|
servers = {}
|
||||||
for name, server_config in config_data.get("servers", {}).items():
|
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(
|
servers[name] = MCPServerConfig(
|
||||||
command=server_config["command"],
|
command=server_config.get("command"),
|
||||||
args=server_config.get("args", []),
|
args=server_config.get("args", []),
|
||||||
env=server_config.get("env", {}),
|
env=server_config.get("env", {}),
|
||||||
name=server_config.get("name", name),
|
name=server_config.get("name", name),
|
||||||
description=server_config.get("description"),
|
description=server_config.get("description"),
|
||||||
enabled=server_config.get("enabled", True)
|
enabled=server_config.get("enabled", True),
|
||||||
|
transport=transport_config
|
||||||
)
|
)
|
||||||
|
|
||||||
self._config = MCPBrowserConfig(
|
self._config = MCPBrowserConfig(
|
||||||
|
|
@ -111,4 +205,4 @@ class ConfigLoader:
|
||||||
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||||
self._merge_configs(base[key], value)
|
self._merge_configs(base[key], value)
|
||||||
else:
|
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(
|
"builtin:memory": MCPServerConfig(
|
||||||
command=["python3", str(base_path / "memory" / "memory_server.py")],
|
command=["python3", str(base_path / "memory" / "memory_server.py")],
|
||||||
name="memory",
|
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(
|
"builtin:patterns": MCPServerConfig(
|
||||||
command=["python3", str(base_path / "pattern_manager" / "pattern_server.py")],
|
command=["python3", str(base_path / "pattern_manager" / "pattern_server.py")],
|
||||||
name="patterns",
|
name="patterns",
|
||||||
description="Auto-response pattern management"
|
description="Auto-response pattern management",
|
||||||
|
enabled=False # Disabled by default, tmux is preferred
|
||||||
),
|
),
|
||||||
"builtin:onboarding": MCPServerConfig(
|
"builtin:onboarding": MCPServerConfig(
|
||||||
command=["python3", str(base_path / "onboarding" / "onboarding_server.py")],
|
command=["python3", str(base_path / "onboarding" / "onboarding_server.py")],
|
||||||
|
|
@ -169,4 +171,4 @@ class MultiServerManager:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error stopping server {name}: {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 typing import Dict, Any, Optional, Union
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import ConfigLoader, MCPBrowserConfig
|
from .config import (
|
||||||
|
ConfigLoader,
|
||||||
|
MCPBrowserConfig,
|
||||||
|
MCPServerConfig,
|
||||||
|
OAuthClientCredentialsConfig,
|
||||||
|
)
|
||||||
from .server import MCPServer
|
from .server import MCPServer
|
||||||
from .multi_server import MultiServerManager
|
from .multi_server import MultiServerManager
|
||||||
from .registry import ToolRegistry
|
from .registry import ToolRegistry
|
||||||
|
|
@ -29,7 +34,7 @@ class MCPBrowser:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config_path: Optional[Path] = None, server_name: Optional[str] = None,
|
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.
|
Initialize MCP Browser.
|
||||||
|
|
||||||
|
|
@ -47,6 +52,7 @@ class MCPBrowser:
|
||||||
self.virtual_handler: Optional[VirtualToolHandler] = None
|
self.virtual_handler: Optional[VirtualToolHandler] = None
|
||||||
self._server_name = server_name
|
self._server_name = server_name
|
||||||
self._enable_builtin_servers = enable_builtin_servers
|
self._enable_builtin_servers = enable_builtin_servers
|
||||||
|
self._transport_override = transport_override
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
self._response_buffer: Dict[Union[str, int], asyncio.Future] = {}
|
self._response_buffer: Dict[Union[str, int], asyncio.Future] = {}
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
|
|
@ -54,6 +60,42 @@ class MCPBrowser:
|
||||||
self._config_watcher = None
|
self._config_watcher = None
|
||||||
self._server_configs = {}
|
self._server_configs = {}
|
||||||
self._config_mtime = None
|
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 def __aenter__(self):
|
||||||
"""Async context manager entry."""
|
"""Async context manager entry."""
|
||||||
|
|
@ -74,10 +116,33 @@ class MCPBrowser:
|
||||||
|
|
||||||
# Determine which server to use
|
# Determine which server to use
|
||||||
server_name = self._server_name or self.config.default_server
|
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")
|
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]
|
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
|
# Create multi-server manager if using built-in servers
|
||||||
if self._enable_builtin_servers:
|
if self._enable_builtin_servers:
|
||||||
|
|
@ -173,7 +238,11 @@ class MCPBrowser:
|
||||||
# Initialize if needed for other requests
|
# Initialize if needed for other requests
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
await self.initialize()
|
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
|
# Check if this is a virtual tool call
|
||||||
if jsonrpc_object.get("method") == "tools/call":
|
if jsonrpc_object.get("method") == "tools/call":
|
||||||
tool_name = jsonrpc_object.get("params", {}).get("name")
|
tool_name = jsonrpc_object.get("params", {}).get("name")
|
||||||
|
|
@ -374,12 +443,16 @@ class MCPBrowser:
|
||||||
if self.server:
|
if self.server:
|
||||||
tools_response = await self.server.send_request("tools/list", {})
|
tools_response = await self.server.send_request("tools/list", {})
|
||||||
|
|
||||||
if "error" in tools_response:
|
# Normalize response structure
|
||||||
raise RuntimeError(f"Failed to list tools: {tools_response['error']}")
|
tools_data = None
|
||||||
|
if isinstance(tools_response, dict):
|
||||||
# Update registry with tools
|
if "tools" in tools_response:
|
||||||
if "result" in tools_response and "tools" in tools_response["result"]:
|
tools_data = tools_response["tools"]
|
||||||
self.registry.update_tools(tools_response["result"]["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
|
# Also get tools from multi-server if enabled
|
||||||
if self.multi_server:
|
if self.multi_server:
|
||||||
|
|
@ -387,7 +460,18 @@ class MCPBrowser:
|
||||||
# Add to registry without going through filter
|
# Add to registry without going through filter
|
||||||
existing_tools = self.registry.raw_tool_list
|
existing_tools = self.registry.raw_tool_list
|
||||||
self.registry.update_tools(existing_tools + builtin_tools)
|
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):
|
def _handle_server_message(self, message: dict):
|
||||||
"""Handle incoming message from MCP server."""
|
"""Handle incoming message from MCP server."""
|
||||||
# Apply incoming filter
|
# Apply incoming filter
|
||||||
|
|
@ -469,4 +553,4 @@ async def create_browser(config_path: Optional[Path] = None,
|
||||||
"""Create and initialize an MCP Browser instance."""
|
"""Create and initialize an MCP Browser instance."""
|
||||||
browser = MCPBrowser(config_path, server_name)
|
browser = MCPBrowser(config_path, server_name)
|
||||||
await browser.initialize()
|
await browser.initialize()
|
||||||
return browser
|
return browser
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,11 @@ import os
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Dict, Any, Callable, List
|
from typing import Optional, Dict, Any, Callable, List, Union
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .buffer import JsonRpcBuffer
|
from .buffer import JsonRpcBuffer
|
||||||
from .config import MCPServerConfig
|
from .config import MCPServerConfig
|
||||||
from .logging_config import get_logger, TRACE
|
from .logging_config import get_logger, TRACE
|
||||||
|
from .streamable_http import StreamableHTTPClient, StreamableHTTPError
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,9 +31,30 @@ class MCPServer:
|
||||||
self._pending_requests: Dict[Union[str, int], asyncio.Future] = {}
|
self._pending_requests: Dict[Union[str, int], asyncio.Future] = {}
|
||||||
self._last_error_time: Optional[float] = None
|
self._last_error_time: Optional[float] = None
|
||||||
self._offline_since: Optional[float] = None
|
self._offline_since: Optional[float] = None
|
||||||
|
self._http_client: Optional[StreamableHTTPClient] = None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the MCP server process."""
|
"""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:
|
if self.process:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -46,6 +66,9 @@ class MCPServer:
|
||||||
self.logger.warning(f"Server has been offline for {offline_duration:.0f}s, skipping start")
|
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")
|
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
|
# Prepare environment
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.update({
|
env.update({
|
||||||
|
|
@ -85,6 +108,13 @@ class MCPServer:
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Stop the MCP server process."""
|
"""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
|
self._running = False
|
||||||
|
|
||||||
if self.process:
|
if self.process:
|
||||||
|
|
@ -116,6 +146,9 @@ class MCPServer:
|
||||||
Returns:
|
Returns:
|
||||||
Response result or raises exception on error
|
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:
|
if not self.process:
|
||||||
raise RuntimeError("MCP server not started")
|
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.logger.error(f"Timeout waiting for response to {method} (timeout={timeout}s)")
|
||||||
self._mark_offline()
|
self._mark_offline()
|
||||||
raise TimeoutError(f"No response for request {request_id}")
|
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):
|
def send_raw(self, message: str):
|
||||||
"""Send raw message to MCP server (for pass-through)."""
|
"""Send raw message to MCP server (for pass-through)."""
|
||||||
|
|
@ -219,4 +287,4 @@ class MCPServer:
|
||||||
try:
|
try:
|
||||||
handler(message)
|
handler(message)
|
||||||
except Exception as e:
|
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
|
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",
|
"aiofiles>=23.0.0",
|
||||||
"jsonpath-ng>=1.6.0",
|
"jsonpath-ng>=1.6.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"aiohttp>=3.9",
|
||||||
"typing-extensions>=4.0.0;python_version<'3.11'",
|
"typing-extensions>=4.0.0;python_version<'3.11'",
|
||||||
],
|
],
|
||||||
extras_require={
|
extras_require={
|
||||||
|
|
@ -359,4 +361,4 @@ setup(
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
],
|
],
|
||||||
keywords="mcp model-context-protocol ai llm tools json-rpc",
|
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