917 lines
33 KiB
Python
917 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MCP Browser command-line interface.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import asyncio
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any
|
|
|
|
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]:
|
|
"""Build JSON-RPC request from command line arguments."""
|
|
if args.command == "tools-list":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/list",
|
|
"params": {}
|
|
}
|
|
|
|
elif args.command == "tools-call":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": args.name,
|
|
"arguments": json.loads(args.arguments)
|
|
}
|
|
}
|
|
|
|
elif args.command == "resources-list":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "resources/list",
|
|
"params": {}
|
|
}
|
|
|
|
elif args.command == "resources-read":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "resources/read",
|
|
"params": {
|
|
"uri": args.uri
|
|
}
|
|
}
|
|
|
|
elif args.command == "prompts-list":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "prompts/list",
|
|
"params": {}
|
|
}
|
|
|
|
elif args.command == "prompts-get":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "prompts/get",
|
|
"params": {
|
|
"name": args.name,
|
|
"arguments": json.loads(args.arguments)
|
|
}
|
|
}
|
|
|
|
elif args.command == "completion":
|
|
params = {}
|
|
if args.ref:
|
|
params["ref"] = {"type": "ref/resource", "uri": args.ref}
|
|
if args.argument:
|
|
params["argument"] = args.argument
|
|
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "completion/complete",
|
|
"params": params
|
|
}
|
|
|
|
elif args.command == "jsonrpc":
|
|
request = json.loads(args.request)
|
|
if "jsonrpc" not in request:
|
|
request["jsonrpc"] = "2.0"
|
|
if "id" not in request:
|
|
request["id"] = 1
|
|
return request
|
|
|
|
else:
|
|
raise ValueError(f"Unknown command: {args.command}")
|
|
|
|
|
|
def format_mcp_response(args, request: Dict[str, Any], response: Dict[str, Any]):
|
|
"""Format and print MCP response based on command."""
|
|
logger = get_logger(__name__)
|
|
if args.debug:
|
|
logger.debug(f"Request: {json.dumps(request)}")
|
|
logger.debug(f"Response: {json.dumps(response)}")
|
|
|
|
# Format output based on command
|
|
if args.command == "tools-list" and "result" in response:
|
|
tools = response["result"].get("tools", [])
|
|
print(f"Found {len(tools)} tools:")
|
|
for tool in tools:
|
|
print(f" - {tool['name']}: {tool.get('description', 'No description')}")
|
|
|
|
elif args.command == "tools-call" and "result" in response:
|
|
# Format tool response
|
|
result = response["result"]
|
|
if "content" in result:
|
|
for content in result["content"]:
|
|
if content.get("type") == "text":
|
|
print(content.get("text", ""))
|
|
else:
|
|
print(json.dumps(content, indent=2))
|
|
else:
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif args.command == "resources-list" and "result" in response:
|
|
resources = response["result"].get("resources", [])
|
|
print(f"Found {len(resources)} resources:")
|
|
for res in resources:
|
|
print(f" - {res['uri']}: {res.get('name', 'Unnamed')}")
|
|
|
|
elif args.command == "prompts-list" and "result" in response:
|
|
prompts = response["result"].get("prompts", [])
|
|
print(f"Found {len(prompts)} prompts:")
|
|
for prompt in prompts:
|
|
print(f" - {prompt['name']}: {prompt.get('description', 'No description')}")
|
|
|
|
elif args.command == "jsonrpc":
|
|
# For raw JSON-RPC, output the full response as JSON
|
|
print(json.dumps(response))
|
|
|
|
else:
|
|
# Default: pretty print result
|
|
if "result" in response:
|
|
print(json.dumps(response["result"], indent=2))
|
|
elif "error" in response:
|
|
print(f"Error: {response['error'].get('message', 'Unknown error')}")
|
|
if args.debug:
|
|
logger.debug(f"Error details: {json.dumps(response['error'])}")
|
|
else:
|
|
print(json.dumps(response, indent=2))
|
|
|
|
|
|
async def interactive_mode(browser: MCPBrowser):
|
|
"""Run MCP Browser in interactive mode."""
|
|
print("MCP Browser Interactive Mode")
|
|
print("=" * 50)
|
|
print(f"Server: {browser._server_name or browser.config.default_server}")
|
|
print(f"Sparse mode: {'enabled' if browser.config.sparse_mode else 'disabled'}")
|
|
print("Type 'help' for commands, 'exit' to quit\n")
|
|
|
|
while True:
|
|
try:
|
|
command = input("> ").strip()
|
|
|
|
if command == "exit":
|
|
break
|
|
elif command == "help":
|
|
print("\nCommands:")
|
|
print(" list - List available tools (sparse mode)")
|
|
print(" discover <path> - Discover tools using JSONPath")
|
|
print(" call <json> - Execute JSON-RPC call")
|
|
print(" onboard [<id>] - Show/set onboarding for identity")
|
|
print(" reload - Reload configuration")
|
|
print(" status - Show connection status")
|
|
print(" exit - Exit interactive mode")
|
|
print("\nExamples:")
|
|
print(' discover $.tools[*].name # Get all tool names')
|
|
print(' discover $.tools[0].inputSchema # Get first tool schema')
|
|
print(' call {"method": "tools/list"} # Raw JSON-RPC call')
|
|
print(' onboard MyProject # Get project onboarding')
|
|
print('\nJSONPath syntax:')
|
|
print(' $ - Root object')
|
|
print(' .tools[*] - All tools')
|
|
print(' .tools[0] - First tool')
|
|
print(' .tools[*].name - All tool names')
|
|
continue
|
|
|
|
elif command.startswith("discover "):
|
|
jsonpath = command[9:]
|
|
result = browser.discover(jsonpath)
|
|
print(json.dumps(result, indent=2))
|
|
|
|
elif command.startswith("call "):
|
|
json_str = command[5:]
|
|
request = json.loads(json_str)
|
|
if "jsonrpc" not in request:
|
|
request["jsonrpc"] = "2.0"
|
|
response = await browser.call(request)
|
|
print(json.dumps(response, indent=2))
|
|
|
|
elif command.startswith("onboard "):
|
|
identity = command[8:]
|
|
response = await browser.call({
|
|
"jsonrpc": "2.0",
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "onboarding",
|
|
"arguments": {"identity": identity}
|
|
}
|
|
})
|
|
if "result" in response:
|
|
print(response["result"]["content"][0]["text"])
|
|
|
|
elif command == "list":
|
|
response = await browser.call({
|
|
"jsonrpc": "2.0",
|
|
"method": "tools/list"
|
|
})
|
|
if "result" in response:
|
|
tools = response["result"]["tools"]
|
|
for tool in tools:
|
|
print(f"- {tool['name']}: {tool['description']}")
|
|
|
|
else:
|
|
print("Unknown command. Type 'help' for available commands.")
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nUse 'exit' to quit")
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
|
|
|
|
def show_available_servers(config_path: Optional[str] = None):
|
|
"""Show list of available MCP servers from configuration."""
|
|
loader = ConfigLoader(Path(config_path) if config_path else None)
|
|
config = loader.load()
|
|
|
|
print("Available MCP Servers:")
|
|
print("=" * 50)
|
|
|
|
for name, server in config.servers.items():
|
|
print(f"\n{name}:")
|
|
print(f" Description: {server.description or 'No description'}")
|
|
print(f" Command: {' '.join(server.command) if server.command else 'Built-in only'}")
|
|
if server.env:
|
|
print(f" Environment: {', '.join(server.env.keys())}")
|
|
|
|
print(f"\nDefault server: {config.default_server}")
|
|
print(f"Config location: {loader.config_path}")
|
|
|
|
|
|
def show_configuration(config_path: Optional[str] = None):
|
|
"""Show current configuration file path and content."""
|
|
loader = ConfigLoader(Path(config_path) if config_path else None)
|
|
|
|
print(f"Configuration file: {loader.config_path}")
|
|
print("=" * 50)
|
|
|
|
if loader.config_path.exists():
|
|
with open(loader.config_path) as f:
|
|
print(f.read())
|
|
else:
|
|
print("Configuration file not found. Will be created on first run.")
|
|
|
|
|
|
async def test_server_connection(browser: MCPBrowser, server_name: Optional[str] = None):
|
|
"""Test connection to specified MCP server."""
|
|
print(f"Testing connection to server: {server_name or 'default'}")
|
|
print("=" * 50)
|
|
|
|
try:
|
|
await browser.initialize()
|
|
print("✓ Successfully connected to server")
|
|
|
|
# Try to list tools
|
|
response = await browser.call({
|
|
"jsonrpc": "2.0",
|
|
"method": "tools/list"
|
|
})
|
|
|
|
if "result" in response:
|
|
tools = response["result"]["tools"]
|
|
print(f"✓ Server provides {len(tools)} tools")
|
|
if browser.config.sparse_mode:
|
|
print(" (Showing sparse tools only)")
|
|
else:
|
|
print("✗ Failed to list tools")
|
|
|
|
except Exception as e:
|
|
print(f"✗ Connection failed: {e}")
|
|
return
|
|
finally:
|
|
await browser.close()
|
|
|
|
print("\nConnection test completed successfully!")
|
|
|
|
|
|
async def handle_mcp_command(args):
|
|
"""Handle MCP method commands by simulating JSON-RPC calls."""
|
|
# Check if we should use daemon mode
|
|
socket_path = get_socket_path(args.server)
|
|
|
|
if hasattr(args, 'use_daemon') and args.use_daemon and is_daemon_running(socket_path):
|
|
# Use daemon client
|
|
async with MCPBrowserClient(socket_path) as client:
|
|
request = build_mcp_request(args)
|
|
response = await client.call(request)
|
|
format_mcp_response(args, request, response)
|
|
return
|
|
|
|
# Create browser directly
|
|
config_path = Path(args.config) if args.config else None
|
|
|
|
# Set debug in config if requested
|
|
if args.debug and config_path is None:
|
|
from .config import ConfigLoader
|
|
loader = ConfigLoader()
|
|
config = loader.load()
|
|
config.debug = True
|
|
|
|
browser = MCPBrowser(
|
|
server_name=args.server,
|
|
config_path=config_path,
|
|
enable_builtin_servers=not args.no_builtin,
|
|
transport_override=getattr(args, "transport_override", None)
|
|
)
|
|
|
|
try:
|
|
await browser.initialize()
|
|
|
|
# Build and send request
|
|
request = build_mcp_request(args)
|
|
response = await browser.call(request)
|
|
|
|
# Format response
|
|
format_mcp_response(args, request, response)
|
|
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
if args.debug:
|
|
import traceback
|
|
traceback.print_exc(file=sys.stderr)
|
|
finally:
|
|
await browser.close()
|
|
|
|
|
|
async def run_daemon_mode(browser: MCPBrowser, socket_path: Path):
|
|
"""Run MCP Browser in daemon mode."""
|
|
daemon = MCPBrowserDaemon(browser, socket_path)
|
|
await daemon.start()
|
|
|
|
|
|
async def start_daemon_background(args):
|
|
"""Start daemon in background."""
|
|
socket_path = get_socket_path(args.server)
|
|
|
|
if is_daemon_running(socket_path):
|
|
print(f"Killing existing daemon for server: {args.server or 'default'}")
|
|
if kill_daemon_with_children(socket_path):
|
|
print("Existing daemon and children killed successfully")
|
|
# Wait a moment for cleanup
|
|
import time
|
|
time.sleep(0.5)
|
|
else:
|
|
print("Warning: Failed to kill existing daemon cleanly")
|
|
|
|
# Fork to background
|
|
pid = os.fork()
|
|
if pid > 0:
|
|
# Parent process
|
|
print(f"Starting daemon in background (PID: {pid})")
|
|
return
|
|
|
|
# Child process
|
|
# Detach from terminal
|
|
os.setsid()
|
|
|
|
# Second fork to prevent zombie processes
|
|
try:
|
|
pid = os.fork()
|
|
if pid > 0:
|
|
# First child exits, orphaning the daemon
|
|
sys.exit(0)
|
|
except OSError as e:
|
|
print(f"Fork #2 failed: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Close file descriptors
|
|
sys.stdin.close()
|
|
sys.stdout.close()
|
|
sys.stderr.close()
|
|
|
|
# Redirect stdout/stderr to log file
|
|
log_dir = socket_path.parent / "logs"
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
log_file = log_dir / f"mcp-browser-{args.server or 'default'}.log"
|
|
|
|
# Open new file descriptors
|
|
sys.stdin = open(os.devnull, 'r')
|
|
sys.stdout = open(log_file, 'a', buffering=1)
|
|
sys.stderr = sys.stdout
|
|
|
|
# Create browser
|
|
config_path = Path(args.config) if args.config else None
|
|
browser = MCPBrowser(
|
|
server_name=args.server,
|
|
config_path=config_path,
|
|
enable_builtin_servers=not args.no_builtin,
|
|
transport_override=getattr(args, "transport_override", None)
|
|
)
|
|
|
|
# Run daemon
|
|
try:
|
|
asyncio.run(run_daemon_mode(browser, socket_path))
|
|
except Exception as e:
|
|
print(f"Daemon error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def stop_daemon(args):
|
|
"""Stop running daemon and all child processes."""
|
|
socket_path = get_socket_path(args.server)
|
|
|
|
if not is_daemon_running(socket_path):
|
|
print(f"No daemon running for server: {args.server or 'default'}")
|
|
return
|
|
|
|
pid_file = socket_path.with_suffix('.pid')
|
|
try:
|
|
pid = int(pid_file.read_text().strip())
|
|
print(f"Stopping daemon (PID: {pid}) and all child processes...")
|
|
|
|
if kill_daemon_with_children(socket_path):
|
|
print("Daemon and all children stopped successfully")
|
|
else:
|
|
print("Warning: Daemon may not have been stopped cleanly")
|
|
except Exception as e:
|
|
print(f"Error stopping daemon: {e}")
|
|
|
|
|
|
def show_daemon_status(args):
|
|
"""Show daemon status."""
|
|
socket_path = get_socket_path(args.server)
|
|
|
|
if is_daemon_running(socket_path):
|
|
pid_file = socket_path.with_suffix('.pid')
|
|
pid = pid_file.read_text().strip()
|
|
print(f"Daemon running (PID: {pid})")
|
|
print(f"Socket: {socket_path}")
|
|
else:
|
|
print(f"No daemon running for server: {args.server or 'default'}")
|
|
|
|
|
|
async def run_server_mode(browser: MCPBrowser):
|
|
"""Run MCP Browser as an MCP server (stdin/stdout)."""
|
|
import sys
|
|
|
|
# Don't initialize here - let it happen lazily when Claude sends initialize request
|
|
# This prevents timeout issues when the browser tries to connect to upstream servers
|
|
|
|
# Read JSON-RPC from stdin line by line
|
|
while True:
|
|
try:
|
|
line = sys.stdin.readline()
|
|
if not line: # EOF
|
|
break
|
|
|
|
line = line.strip()
|
|
if not line: # Empty line
|
|
continue
|
|
|
|
try:
|
|
request = json.loads(line)
|
|
response = await browser.call(request)
|
|
print(json.dumps(response))
|
|
sys.stdout.flush()
|
|
except json.JSONDecodeError as e:
|
|
# Send error response for malformed JSON
|
|
error_response = {
|
|
"jsonrpc": "2.0",
|
|
"id": None,
|
|
"error": {
|
|
"code": -32700,
|
|
"message": "Parse error",
|
|
"data": str(e)
|
|
}
|
|
}
|
|
print(json.dumps(error_response))
|
|
sys.stdout.flush()
|
|
|
|
except KeyboardInterrupt:
|
|
break
|
|
except EOFError:
|
|
break
|
|
|
|
|
|
async def run_server_mode_with_daemon(socket_path: Path):
|
|
"""Run as MCP server but forward to daemon."""
|
|
import sys
|
|
|
|
async with MCPBrowserClient(socket_path) as client:
|
|
# Read JSON-RPC from stdin line by line, forward to daemon
|
|
while True:
|
|
try:
|
|
line = sys.stdin.readline()
|
|
if not line: # EOF
|
|
break
|
|
|
|
line = line.strip()
|
|
if not line: # Empty line
|
|
continue
|
|
|
|
try:
|
|
request = json.loads(line)
|
|
response = await client.call(request)
|
|
print(json.dumps(response))
|
|
sys.stdout.flush()
|
|
except json.JSONDecodeError as e:
|
|
# Send error response for malformed JSON
|
|
error_response = {
|
|
"jsonrpc": "2.0",
|
|
"id": None,
|
|
"error": {
|
|
"code": -32700,
|
|
"message": "Parse error",
|
|
"data": str(e)
|
|
}
|
|
}
|
|
print(json.dumps(error_response))
|
|
sys.stdout.flush()
|
|
|
|
except KeyboardInterrupt:
|
|
break
|
|
except EOFError:
|
|
break
|
|
|
|
|
|
async def interactive_mode_with_daemon(socket_path: Path):
|
|
"""Run interactive mode connected to daemon."""
|
|
async with MCPBrowserClient(socket_path) as client:
|
|
print("MCP Browser Interactive Mode (Daemon)")
|
|
print("=" * 50)
|
|
print(f"Connected to daemon at: {socket_path}")
|
|
print("Type 'help' for commands, 'exit' to quit\n")
|
|
|
|
while True:
|
|
try:
|
|
command = input("> ").strip()
|
|
|
|
if command == "exit":
|
|
break
|
|
elif command == "help":
|
|
print("\nCommands:")
|
|
print(" list - List available tools (sparse mode)")
|
|
print(" discover <path> - Discover tools using JSONPath")
|
|
print(" call <json> - Execute JSON-RPC call")
|
|
print(" exit - Exit interactive mode")
|
|
continue
|
|
|
|
elif command.startswith("call "):
|
|
json_str = command[5:]
|
|
request = json.loads(json_str)
|
|
if "jsonrpc" not in request:
|
|
request["jsonrpc"] = "2.0"
|
|
if "id" not in request:
|
|
request["id"] = 1
|
|
response = await client.call(request)
|
|
print(json.dumps(response, indent=2))
|
|
|
|
elif command == "list":
|
|
response = await client.call({
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/list"
|
|
})
|
|
if "result" in response:
|
|
tools = response["result"]["tools"]
|
|
for tool in tools:
|
|
print(f"- {tool['name']}: {tool['description']}")
|
|
|
|
else:
|
|
print("Unknown command. Type 'help' for available commands.")
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nUse 'exit' to quit")
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
# Parse args first to check for log configuration
|
|
args_parser = argparse.ArgumentParser(add_help=False)
|
|
args_parser.add_argument("--debug", action="store_true")
|
|
args_parser.add_argument("--log-level")
|
|
args_parser.add_argument("--log-file")
|
|
early_args, _ = args_parser.parse_known_args()
|
|
|
|
# Setup logging before anything else
|
|
# IMPORTANT: In server mode, we must not log to stderr as it may interfere
|
|
# Determine if we're in server mode early
|
|
is_server_mode = "--mode" in sys.argv and "server" in sys.argv
|
|
|
|
log_file = Path(early_args.log_file) if early_args.log_file else None
|
|
|
|
# In server mode, use syslog unless a log file is specified
|
|
use_syslog = is_server_mode and not log_file
|
|
|
|
setup_logging(
|
|
debug=early_args.debug,
|
|
log_file=log_file,
|
|
log_level=early_args.log_level,
|
|
use_syslog=use_syslog
|
|
)
|
|
|
|
# Import version
|
|
from . import __version__
|
|
|
|
# Now create the full parser
|
|
parser = argparse.ArgumentParser(
|
|
description=f"MCP Browser v{__version__} - Universal Model Context Protocol Interface",
|
|
epilog="""
|
|
Examples:
|
|
# Interactive mode
|
|
mcp-browser # Start interactive mode with default server
|
|
mcp-browser --server brave-search # Use Brave Search server
|
|
|
|
# Configuration
|
|
mcp-browser --list-servers # List configured servers
|
|
mcp-browser --show-config # Show current configuration
|
|
mcp-browser --test # Test server connection
|
|
|
|
# MCP method commands
|
|
mcp-browser tools-list # List available tools
|
|
mcp-browser tools-call brave_web_search '{"query": "MCP protocol"}'
|
|
mcp-browser resources-list # List available resources
|
|
mcp-browser resources-read "file:///path/to/file"
|
|
mcp-browser prompts-list # List available prompts
|
|
mcp-browser prompts-get "greeting" --arguments '{"name": "Alice"}'
|
|
|
|
# Raw JSON-RPC
|
|
mcp-browser jsonrpc '{"method": "tools/list", "params": {}}'
|
|
|
|
# Server mode
|
|
mcp-browser --mode server # Run as MCP server (stdin/stdout)
|
|
|
|
Configuration:
|
|
Default config: ~/.config/mcp-browser/config.yaml
|
|
First run creates default configuration with examples
|
|
|
|
Environment:
|
|
Set API keys as needed: BRAVE_API_KEY, GITHUB_TOKEN, etc.
|
|
Or source from: source ~/.secrets/api-keys.sh
|
|
""",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
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", "streamable-http"],
|
|
default="interactive", help="Operating mode (default: interactive)")
|
|
parser.add_argument("--no-sparse", action="store_true",
|
|
help="Disable sparse mode (show all tools)")
|
|
parser.add_argument("--no-builtin", action="store_true",
|
|
help="Disable built-in servers (screen, memory, patterns)")
|
|
parser.add_argument("--list-servers", action="store_true",
|
|
help="List available MCP servers from config")
|
|
parser.add_argument("--show-config", action="store_true",
|
|
help="Show current configuration path and content")
|
|
parser.add_argument("--test", action="store_true",
|
|
help="Test connection to specified server")
|
|
parser.add_argument("--debug", action="store_true",
|
|
help="Enable debug output")
|
|
parser.add_argument("--log-level", choices=["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"],
|
|
help="Set logging level")
|
|
parser.add_argument("--log-file", help="Log to file instead of stderr")
|
|
parser.add_argument("--use-daemon", action="store_true",
|
|
help="Automatically use daemon if available")
|
|
parser.add_argument("--daemon-start", action="store_true",
|
|
help="Start daemon in background")
|
|
parser.add_argument("--daemon-stop", action="store_true",
|
|
help="Stop running daemon")
|
|
parser.add_argument("--daemon-status", action="store_true",
|
|
help="Check daemon status")
|
|
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")
|
|
|
|
# tools/list command
|
|
subparsers.add_parser("tools-list", help="List available tools")
|
|
|
|
# tools/call command
|
|
call_tool = subparsers.add_parser("tools-call", help="Call a tool")
|
|
call_tool.add_argument("name", help="Tool name")
|
|
call_tool.add_argument("arguments", help="Tool arguments as JSON")
|
|
|
|
# resources/list command
|
|
subparsers.add_parser("resources-list", help="List available resources")
|
|
|
|
# resources/read command
|
|
read_resource = subparsers.add_parser("resources-read", help="Read a resource")
|
|
read_resource.add_argument("uri", help="Resource URI")
|
|
|
|
# prompts/list command
|
|
subparsers.add_parser("prompts-list", help="List available prompts")
|
|
|
|
# prompts/get command
|
|
get_prompt = subparsers.add_parser("prompts-get", help="Get a prompt")
|
|
get_prompt.add_argument("name", help="Prompt name")
|
|
get_prompt.add_argument("--arguments", "-a", help="Prompt arguments as JSON", default="{}")
|
|
|
|
# completion command
|
|
completion = subparsers.add_parser("completion", help="Get completion")
|
|
completion.add_argument("--ref", help="Reference for completion")
|
|
completion.add_argument("--argument", help="Argument name")
|
|
|
|
# Generic JSON-RPC command
|
|
jsonrpc = subparsers.add_parser("jsonrpc", help="Send raw JSON-RPC request")
|
|
jsonrpc.add_argument("request", help="JSON-RPC request")
|
|
|
|
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)
|
|
return
|
|
|
|
if args.show_config:
|
|
show_configuration(args.config)
|
|
return
|
|
|
|
# Handle daemon management commands
|
|
if args.daemon_start:
|
|
asyncio.run(start_daemon_background(args))
|
|
return
|
|
|
|
if args.daemon_stop:
|
|
stop_daemon(args)
|
|
return
|
|
|
|
if args.daemon_status:
|
|
show_daemon_status(args)
|
|
return
|
|
|
|
# Handle MCP method commands
|
|
if args.command:
|
|
asyncio.run(handle_mcp_command(args))
|
|
return
|
|
|
|
# Create browser
|
|
config_path = Path(args.config) if args.config else None
|
|
|
|
# Apply log level to config if set
|
|
if args.log_level == "TRACE" and config_path is None:
|
|
from .config import ConfigLoader
|
|
loader = ConfigLoader()
|
|
loader.load()
|
|
# TRACE level shows raw I/O
|
|
|
|
browser = MCPBrowser(
|
|
server_name=args.server,
|
|
config_path=config_path,
|
|
enable_builtin_servers=not args.no_builtin,
|
|
transport_override=transport_override
|
|
)
|
|
|
|
# Handle test mode
|
|
if args.test:
|
|
asyncio.run(test_server_connection(browser, args.server))
|
|
return
|
|
|
|
# Run in appropriate mode
|
|
if args.mode == "server":
|
|
# Run as MCP server (stdin/stdout) - can connect to daemon
|
|
socket_path = get_socket_path(args.server)
|
|
if args.use_daemon and is_daemon_running(socket_path):
|
|
# Use daemon as backend
|
|
asyncio.run(run_server_mode_with_daemon(socket_path))
|
|
else:
|
|
asyncio.run(run_server_mode(browser))
|
|
elif args.mode == "daemon":
|
|
# 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)
|
|
if args.use_daemon and is_daemon_running(socket_path):
|
|
asyncio.run(interactive_mode_with_daemon(socket_path))
|
|
else:
|
|
asyncio.run(async_main(browser))
|
|
|
|
|
|
async def async_main(browser: MCPBrowser):
|
|
"""Async main for interactive mode."""
|
|
try:
|
|
await browser.initialize()
|
|
await interactive_mode(browser)
|
|
finally:
|
|
await browser.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|