Fix server CPU loops and refactor to client/daemon architecture
Major refactoring to fix high CPU usage and improve architecture:
## Fixed Issues
- MCP servers were stuck in infinite loops when stdin closed
- Fixed by checking for EOF (empty string) from stdin.read()
- Fixed indentation error in base.py causing syntax errors
## New Architecture
- Split into separate client and daemon binaries:
- `mcp-browser`: Smart client that can use daemon or run standalone
- `mcp-browser-daemon`: Persistent daemon process
- `mcp-browser-legacy`: Old monolithic version (deprecated)
## Client Features
- Auto-detects and uses daemon if available
- Can auto-start daemon if needed
- Falls back to standalone mode
- Supports `--use-daemon` with modes: auto, always, never
- Works as MCP server (stdin/stdout) or CLI tool
## Daemon Features
- Runs as background process with socket server
- Supports multiple concurrent clients
- Can run in foreground with `--foreground`
- Includes systemd service files for production
- Proper signal handling and cleanup
## Usage Examples
```bash
# CLI usage (auto-uses daemon if available)
mcp-browser tools-list
mcp-browser tools-call builtin:patterns::list_patterns '{}'
# Force standalone mode
mcp-browser --use-daemon never tools-list
# Run daemon manually
mcp-browser-daemon --foreground --log-level DEBUG
# Use as MCP server
echo '{"method":"tools/list"}' | mcp-browser --mode server
```
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6025bb1256
commit
6823d41dd1
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
4
setup.py
4
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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=MCP Browser Socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/mcp-browser/mcp-browser.sock
|
||||
SocketMode=0666
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
Loading…
Reference in New Issue