diff --git a/mcp_browser/client_main.py b/mcp_browser/client_main.py new file mode 100644 index 0000000..cf02d5f --- /dev/null +++ b/mcp_browser/client_main.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +MCP Browser Client - Connect to daemon or run standalone. + +This client can: +1. Connect to a running daemon +2. Auto-start daemon if not running +3. Run in standalone mode +4. Act as MCP server (stdin/stdout) +""" + +import os +import sys +import asyncio +import argparse +import json +import subprocess +import time +from pathlib import Path +from typing import Optional, Dict, Any + +from .proxy import MCPBrowser +from .daemon import MCPBrowserClient, get_socket_path, is_daemon_running +from .logging_config import setup_logging, get_logger +from .config import ConfigLoader + + +def start_daemon_if_needed(server_name: Optional[str] = None, timeout: float = 5.0) -> bool: + """Start daemon if not running. Returns True if daemon is available.""" + socket_path = get_socket_path(server_name) + + if is_daemon_running(socket_path): + return True + + # Start daemon + cmd = [sys.executable, "-m", "mcp_browser.daemon_main"] + if server_name: + cmd.extend(["--server", server_name]) + + # Start in background + subprocess.Popen(cmd, start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + # Wait for daemon to start + start_time = time.time() + while time.time() - start_time < timeout: + if is_daemon_running(socket_path): + return True + time.sleep(0.1) + + return False + + +async def run_mcp_server_mode(args): + """Run as MCP server (stdin/stdout) forwarding to daemon.""" + logger = get_logger(__name__) + + # Try to use daemon + socket_path = get_socket_path(args.server) + use_daemon = False + + if args.use_daemon != "never": + if args.use_daemon == "always" or is_daemon_running(socket_path): + use_daemon = True + elif args.use_daemon == "auto": + # Try to start daemon + use_daemon = start_daemon_if_needed(args.server) + + if use_daemon: + logger.info(f"Using daemon at {socket_path}") + async with MCPBrowserClient(socket_path) as client: + # Forward stdin/stdout to daemon + buffer = "" + while True: + try: + chunk = sys.stdin.read(4096) + if not chunk: + break + + buffer += chunk + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + if line.strip(): + try: + request = json.loads(line) + response = await client.call(request) + print(json.dumps(response), flush=True) + except json.JSONDecodeError: + pass + except Exception as e: + logger.error(f"Error forwarding to daemon: {e}") + + except KeyboardInterrupt: + break + except EOFError: + break + else: + # Run standalone + logger.info("Running in standalone mode") + browser = MCPBrowser( + server_name=args.server, + config_path=Path(args.config) if args.config else None, + enable_builtin_servers=not args.no_builtin + ) + + await browser.initialize() + + try: + buffer = "" + while True: + try: + chunk = sys.stdin.read(4096) + if not chunk: + break + + buffer += chunk + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + if line.strip(): + try: + request = json.loads(line) + response = await browser.call(request) + print(json.dumps(response), flush=True) + except json.JSONDecodeError: + pass + + except KeyboardInterrupt: + break + except EOFError: + break + finally: + await browser.close() + + +async def run_command(args, request: Dict[str, Any]): + """Run a single command through daemon or standalone.""" + logger = get_logger(__name__) + + # Try daemon first if enabled + socket_path = get_socket_path(args.server) + + if args.use_daemon != "never": + if is_daemon_running(socket_path) or (args.use_daemon == "auto" and start_daemon_if_needed(args.server)): + logger.debug(f"Using daemon at {socket_path}") + async with MCPBrowserClient(socket_path) as client: + return await client.call(request) + + # Fallback to standalone + logger.debug("Running in standalone mode") + browser = MCPBrowser( + server_name=args.server, + config_path=Path(args.config) if args.config else None, + enable_builtin_servers=not args.no_builtin + ) + + await browser.initialize() + try: + return await browser.call(request) + finally: + await browser.close() + + +def build_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 == "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_response(args, response: Dict[str, Any]): + """Format response for display.""" + if args.json: + print(json.dumps(response)) + return + + if "error" in response: + print(f"Error: {response['error'].get('message', 'Unknown error')}") + return + + result = response.get("result", {}) + + if args.command == "tools-list": + tools = result.get("tools", []) + if tools: + print(f"Found {len(tools)} tools:") + for tool in tools: + print(f" - {tool['name']}: {tool.get('description', '')}") + else: + print("No tools found") + + elif args.command == "tools-call": + 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)) + + else: + print(json.dumps(result, indent=2)) + + +def main(): + """Main entry point for client.""" + parser = argparse.ArgumentParser( + description="MCP Browser Client - Connect to daemon or run standalone", + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + # Connection options + parser.add_argument("--server", "-s", help="Target MCP server name") + parser.add_argument("--config", "-c", help="Custom configuration file path") + parser.add_argument("--use-daemon", choices=["auto", "always", "never"], + default="auto", help="Daemon usage mode (default: auto)") + parser.add_argument("--no-builtin", action="store_true", + help="Disable built-in servers") + + # Output options + parser.add_argument("--json", action="store_true", + help="Output raw JSON responses") + 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") + + # Mode options + parser.add_argument("--mode", choices=["command", "server", "interactive"], + default="command", help="Operating mode") + + # Commands + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # tools/list + tools_list = subparsers.add_parser("tools-list", help="List available tools") + + # tools/call + tools_call = subparsers.add_parser("tools-call", help="Call a tool") + tools_call.add_argument("name", help="Tool name") + tools_call.add_argument("arguments", help="Tool arguments as JSON") + + # Raw JSON-RPC + jsonrpc = subparsers.add_parser("jsonrpc", help="Send raw JSON-RPC request") + jsonrpc.add_argument("request", help="JSON-RPC request") + + args = parser.parse_args() + + # Setup logging + log_file = Path(args.log_file) if args.log_file else None + setup_logging( + debug=args.debug, + log_file=log_file, + log_level=args.log_level + ) + + # Handle modes + if args.mode == "server": + # Run as MCP server + asyncio.run(run_mcp_server_mode(args)) + elif args.mode == "interactive": + # TODO: Implement interactive mode + print("Interactive mode not yet implemented") + else: + # Command mode + if not args.command: + parser.print_help() + sys.exit(1) + + async def run(): + request = build_request(args) + response = await run_command(args, request) + format_response(args, response) + + asyncio.run(run()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mcp_browser/daemon_main.py b/mcp_browser/daemon_main.py new file mode 100644 index 0000000..893b440 --- /dev/null +++ b/mcp_browser/daemon_main.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +MCP Browser Daemon - Socket server for MCP Browser. + +This daemon provides a persistent MCP Browser instance that clients can connect to. +""" + +import os +import sys +import asyncio +import argparse +import signal +from pathlib import Path +from typing import Optional + +from .proxy import MCPBrowser +from .daemon import MCPBrowserDaemon, get_socket_path +from .logging_config import setup_logging, get_logger + + +async def run_daemon(args): + """Run the MCP Browser daemon.""" + logger = get_logger(__name__) + + # Create browser instance + browser = MCPBrowser( + server_name=args.server, + config_path=Path(args.config) if args.config else None, + enable_builtin_servers=not args.no_builtin + ) + + # Get socket path + socket_path = get_socket_path(args.server) + + # Create and run daemon + daemon = MCPBrowserDaemon(browser, socket_path) + + logger.info(f"Starting MCP Browser daemon on {socket_path}") + + try: + await daemon.start() + except KeyboardInterrupt: + logger.info("Daemon shutting down...") + except Exception as e: + logger.error(f"Daemon error: {e}") + raise + finally: + await daemon.stop() + + +def handle_systemd_socket(): + """Check for systemd socket activation.""" + # Check if we're running under systemd with socket activation + listen_pid = os.environ.get('LISTEN_PID') + listen_fds = os.environ.get('LISTEN_FDS') + + if listen_pid and listen_fds and int(listen_pid) == os.getpid(): + # We have systemd socket activation + num_fds = int(listen_fds) + if num_fds > 0: + # Use the first socket FD (SD_LISTEN_FDS_START = 3) + return 3 + return None + + +def main(): + """Main entry point for daemon.""" + parser = argparse.ArgumentParser( + description="MCP Browser Daemon - Persistent socket server", + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument("--server", "-s", help="Target MCP server name") + parser.add_argument("--config", "-c", help="Custom configuration file path") + parser.add_argument("--no-builtin", action="store_true", + help="Disable built-in servers") + 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("--socket", help="Custom socket path (overrides default)") + parser.add_argument("--foreground", "-f", action="store_true", + help="Run in foreground (don't daemonize)") + parser.add_argument("--pid-file", help="Write PID to file") + + args = parser.parse_args() + + # Setup logging + log_file = Path(args.log_file) if args.log_file else None + setup_logging( + debug=args.log_level == "DEBUG", + log_file=log_file, + log_level=args.log_level + ) + + logger = get_logger(__name__) + + # Check for systemd socket activation + systemd_fd = handle_systemd_socket() + if systemd_fd: + logger.info("Running with systemd socket activation") + # TODO: Implement systemd socket handling + + # Handle PID file + if args.pid_file: + with open(args.pid_file, 'w') as f: + f.write(str(os.getpid())) + + # Daemonize if not in foreground mode + if not args.foreground: + # Fork to background + pid = os.fork() + if pid > 0: + # Parent process + print(f"Started daemon with PID {pid}") + sys.exit(0) + + # Child process continues + os.setsid() + + # Redirect stdin/stdout/stderr + with open(os.devnull, 'r') as devnull: + os.dup2(devnull.fileno(), sys.stdin.fileno()) + with open(os.devnull, 'w') as devnull: + os.dup2(devnull.fileno(), sys.stdout.fileno()) + if not args.log_file: + os.dup2(devnull.fileno(), sys.stderr.fileno()) + + # Set up signal handlers + def signal_handler(signum, frame): + logger.info(f"Received signal {signum}") + asyncio.create_task(daemon.stop()) + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Run the daemon + asyncio.run(run_daemon(args)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mcp_servers/base.py b/mcp_servers/base.py index ac6bea9..4a8d80e 100644 --- a/mcp_servers/base.py +++ b/mcp_servers/base.py @@ -121,31 +121,33 @@ class BaseMCPServer(ABC): try: # Try to read available data chunk = sys.stdin.read(4096) - if chunk: - buffer += chunk + if not chunk: + # EOF reached + break + buffer += chunk + + # Process complete lines + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() - # Process complete lines - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) - line = line.strip() - - if line: - try: - request = json.loads(line) - response = await self.handle_request(request) - print(json.dumps(response), flush=True) - except json.JSONDecodeError: - pass - except Exception as e: - error_response = { - "jsonrpc": "2.0", - "id": None, - "error": { - "code": -32700, - "message": "Parse error" - } + if line: + try: + request = json.loads(line) + response = await self.handle_request(request) + print(json.dumps(response), flush=True) + except json.JSONDecodeError: + pass + except Exception as e: + error_response = { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": -32700, + "message": "Parse error" } - print(json.dumps(error_response), flush=True) + } + print(json.dumps(error_response), flush=True) except BlockingIOError: # No data available, sleep briefly diff --git a/setup.py b/setup.py index bc86410..d1d122c 100644 --- a/setup.py +++ b/setup.py @@ -205,7 +205,9 @@ setup( python_requires=">=3.8", entry_points={ "console_scripts": [ - "mcp-browser=mcp_browser.__main__:main", + "mcp-browser=mcp_browser.client_main:main", + "mcp-browser-daemon=mcp_browser.daemon_main:main", + "mcp-browser-legacy=mcp_browser.__main__:main", ], }, cmdclass={ diff --git a/systemd/mcp-browser.service b/systemd/mcp-browser.service new file mode 100644 index 0000000..801516a --- /dev/null +++ b/systemd/mcp-browser.service @@ -0,0 +1,17 @@ +[Unit] +Description=MCP Browser Daemon +After=network.target + +[Service] +Type=forking +ExecStart=/usr/bin/mcp-browser-daemon --pid-file /run/mcp-browser/mcp-browser.pid +PIDFile=/run/mcp-browser/mcp-browser.pid +RuntimeDirectory=mcp-browser +RuntimeDirectoryMode=0755 +User=nobody +Group=nogroup +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/systemd/mcp-browser.socket b/systemd/mcp-browser.socket new file mode 100644 index 0000000..a68acab --- /dev/null +++ b/systemd/mcp-browser.socket @@ -0,0 +1,9 @@ +[Unit] +Description=MCP Browser Socket + +[Socket] +ListenStream=/run/mcp-browser/mcp-browser.sock +SocketMode=0666 + +[Install] +WantedBy=sockets.target \ No newline at end of file