mcp-browser/mcp_browser/client_main.py

317 lines
10 KiB
Python

#!/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
# In server mode, use syslog unless a log file is specified
use_syslog = args.mode == "server" and not log_file
setup_logging(
debug=args.debug,
log_file=log_file,
log_level=args.log_level,
use_syslog=use_syslog
)
# Log startup message
logger = get_logger(__name__)
if args.mode == "server":
logger.debug("mcp-browser client started in server mode")
# 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()