#!/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 - Discover tools using JSONPath") print(" call - Execute JSON-RPC call") print(" onboard [] - 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 - Discover tools using JSONPath") print(" call - 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: ~/.claude/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()