Add daemon mode and improve debug output
- Implement socket-based daemon mode using Unix domain sockets - Allows multiple clients to connect to a single MCP Browser instance - Supports automatic daemon detection and connection - Added --daemon-start, --daemon-stop, --daemon-status commands - Improve debug output handling - All debug output now goes to stderr instead of stdout - Added debug_print() and debug_json() utilities - Debug messages show before and after server communication - Fix builtin-only mode handling - Properly route tools/list and tools/call when no external server - Apply sparse mode filtering to builtin server tools - Improve JSON-RPC command output - Raw jsonrpc command now outputs proper JSON for piping - Other commands use human-friendly formatting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
33ff1c33f5
commit
4a7d3cdc7f
|
|
@ -3,17 +3,158 @@
|
||||||
MCP Browser command-line interface.
|
MCP Browser command-line interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Dict, Any
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from .proxy import MCPBrowser
|
from .proxy import MCPBrowser
|
||||||
from .config import ConfigLoader
|
from .config import ConfigLoader
|
||||||
from .default_configs import ConfigManager
|
from .default_configs import ConfigManager
|
||||||
|
from .daemon import MCPBrowserDaemon, MCPBrowserClient, get_socket_path, is_daemon_running
|
||||||
|
from .utils import debug_print, debug_json
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
if args.debug:
|
||||||
|
debug_json("Request", request)
|
||||||
|
debug_json("Response", 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:
|
||||||
|
debug_json("Error details", response["error"])
|
||||||
|
else:
|
||||||
|
print(json.dumps(response, indent=2))
|
||||||
|
|
||||||
|
|
||||||
async def interactive_mode(browser: MCPBrowser):
|
async def interactive_mode(browser: MCPBrowser):
|
||||||
|
|
@ -161,6 +302,130 @@ async def test_server_connection(browser: MCPBrowser, server_name: Optional[str]
|
||||||
print("\nConnection test completed successfully!")
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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"Daemon already running for server: {args.server or 'default'}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Redirect stdout/stderr to log file
|
||||||
|
log_dir = socket_path.parent / "logs"
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
log_file = log_dir / f"mcp-browser-{args.server or 'default'}.log"
|
||||||
|
|
||||||
|
with open(log_file, 'a') as log:
|
||||||
|
sys.stdout = log
|
||||||
|
sys.stderr = log
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run daemon
|
||||||
|
asyncio.run(run_daemon_mode(browser, socket_path))
|
||||||
|
|
||||||
|
|
||||||
|
def stop_daemon(args):
|
||||||
|
"""Stop running daemon."""
|
||||||
|
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())
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
print(f"Sent SIGTERM to daemon (PID: {pid})")
|
||||||
|
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):
|
async def run_server_mode(browser: MCPBrowser):
|
||||||
"""Run MCP Browser as an MCP server (stdin/stdout)."""
|
"""Run MCP Browser as an MCP server (stdin/stdout)."""
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -193,16 +458,116 @@ async def run_server_mode(browser: MCPBrowser):
|
||||||
break
|
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, forward to daemon, write to stdout
|
||||||
|
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))
|
||||||
|
sys.stdout.flush()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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():
|
def main():
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="MCP Browser - Universal Model Context Protocol Interface",
|
description="MCP Browser - Universal Model Context Protocol Interface",
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
|
# Interactive mode
|
||||||
mcp-browser # Start interactive mode with default server
|
mcp-browser # Start interactive mode with default server
|
||||||
mcp-browser --server brave-search # Use Brave Search server
|
mcp-browser --server brave-search # Use Brave Search server
|
||||||
|
|
||||||
|
# Configuration
|
||||||
mcp-browser --list-servers # List configured servers
|
mcp-browser --list-servers # List configured servers
|
||||||
mcp-browser --show-config # Show current configuration
|
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)
|
mcp-browser --mode server # Run as MCP server (stdin/stdout)
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
|
|
@ -217,7 +582,7 @@ Environment:
|
||||||
)
|
)
|
||||||
parser.add_argument("--server", "-s", help="Target MCP server name (see --list-servers)")
|
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("--config", "-c", help="Custom configuration file path")
|
||||||
parser.add_argument("--mode", choices=["interactive", "server"],
|
parser.add_argument("--mode", choices=["interactive", "server", "daemon"],
|
||||||
default="interactive", help="Operating mode (default: interactive)")
|
default="interactive", help="Operating mode (default: interactive)")
|
||||||
parser.add_argument("--no-sparse", action="store_true",
|
parser.add_argument("--no-sparse", action="store_true",
|
||||||
help="Disable sparse mode (show all tools)")
|
help="Disable sparse mode (show all tools)")
|
||||||
|
|
@ -231,6 +596,49 @@ Environment:
|
||||||
help="Test connection to specified server")
|
help="Test connection to specified server")
|
||||||
parser.add_argument("--debug", action="store_true",
|
parser.add_argument("--debug", action="store_true",
|
||||||
help="Enable debug output")
|
help="Enable debug output")
|
||||||
|
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")
|
||||||
|
|
||||||
|
# MCP method commands
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="MCP methods")
|
||||||
|
|
||||||
|
# tools/list command
|
||||||
|
list_tools = 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
|
||||||
|
list_resources = 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
|
||||||
|
list_prompts = 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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
@ -243,6 +651,24 @@ Environment:
|
||||||
show_configuration(args.config)
|
show_configuration(args.config)
|
||||||
return
|
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
|
# Create browser
|
||||||
config_path = Path(args.config) if args.config else None
|
config_path = Path(args.config) if args.config else None
|
||||||
browser = MCPBrowser(
|
browser = MCPBrowser(
|
||||||
|
|
@ -258,7 +684,22 @@ Environment:
|
||||||
|
|
||||||
# Run in appropriate mode
|
# Run in appropriate mode
|
||||||
if args.mode == "server":
|
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))
|
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))
|
||||||
|
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:
|
else:
|
||||||
asyncio.run(async_main(browser))
|
asyncio.run(async_main(browser))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
"""
|
||||||
|
MCP Browser daemon implementation using Unix domain sockets.
|
||||||
|
|
||||||
|
Provides a persistent MCP Browser instance that multiple clients can connect to,
|
||||||
|
allowing shared state and better performance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError:
|
||||||
|
# Fallback if psutil is not available
|
||||||
|
class psutil:
|
||||||
|
@staticmethod
|
||||||
|
def pid_exists(pid):
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from .proxy import MCPBrowser
|
||||||
|
from .utils import debug_print, debug_json
|
||||||
|
|
||||||
|
|
||||||
|
class MCPBrowserDaemon:
|
||||||
|
"""Daemon mode for MCP Browser using Unix domain sockets."""
|
||||||
|
|
||||||
|
def __init__(self, browser: MCPBrowser, socket_path: Path):
|
||||||
|
self.browser = browser
|
||||||
|
self.socket_path = socket_path
|
||||||
|
self.server: Optional[asyncio.Server] = None
|
||||||
|
self._running = False
|
||||||
|
self._clients: set = set()
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the daemon server."""
|
||||||
|
# Ensure socket directory exists
|
||||||
|
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Remove existing socket if present
|
||||||
|
if self.socket_path.exists():
|
||||||
|
self.socket_path.unlink()
|
||||||
|
|
||||||
|
# Create Unix domain socket server
|
||||||
|
self.server = await asyncio.start_unix_server(
|
||||||
|
self._handle_client,
|
||||||
|
path=str(self.socket_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
os.chmod(self.socket_path, 0o600)
|
||||||
|
|
||||||
|
# Write PID file
|
||||||
|
pid_file = self.socket_path.with_suffix('.pid')
|
||||||
|
pid_file.write_text(str(os.getpid()))
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# Set up signal handlers
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
|
||||||
|
debug_print(f"MCP Browser daemon started on {self.socket_path}")
|
||||||
|
debug_print(f"PID: {os.getpid()}")
|
||||||
|
|
||||||
|
# Initialize browser
|
||||||
|
await self.browser.initialize()
|
||||||
|
|
||||||
|
# Run server
|
||||||
|
async with self.server:
|
||||||
|
await self.server.serve_forever()
|
||||||
|
|
||||||
|
def _signal_handler(self, signum, frame):
|
||||||
|
"""Handle shutdown signals."""
|
||||||
|
debug_print(f"\nReceived signal {signum}, shutting down...")
|
||||||
|
self._running = False
|
||||||
|
if self.server:
|
||||||
|
self.server.close()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
|
"""Handle a client connection."""
|
||||||
|
client_addr = writer.get_extra_info('peername')
|
||||||
|
debug_print(f"Client connected: {client_addr}")
|
||||||
|
self._clients.add(writer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
buffer = ""
|
||||||
|
while self._running:
|
||||||
|
# Read data from client
|
||||||
|
data = await reader.read(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
buffer += data.decode('utf-8')
|
||||||
|
|
||||||
|
# Process complete JSON objects
|
||||||
|
while '\n' in buffer:
|
||||||
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
if line.strip():
|
||||||
|
await self._process_request(line, writer)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
debug_print(f"Client error: {e}")
|
||||||
|
finally:
|
||||||
|
self._clients.discard(writer)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
debug_print(f"Client disconnected: {client_addr}")
|
||||||
|
|
||||||
|
async def _process_request(self, line: str, writer: asyncio.StreamWriter):
|
||||||
|
"""Process a JSON-RPC request from client."""
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
|
||||||
|
# Add debug output if configured
|
||||||
|
if self.browser.config and self.browser.config.debug:
|
||||||
|
debug_json("Daemon received", request)
|
||||||
|
|
||||||
|
# Forward to browser
|
||||||
|
response = await self.browser.call(request)
|
||||||
|
|
||||||
|
# Send response back to client
|
||||||
|
response_str = json.dumps(response) + '\n'
|
||||||
|
writer.write(response_str.encode('utf-8'))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
if self.browser.config and self.browser.config.debug:
|
||||||
|
debug_print(f"Daemon sent: {response_str.strip()}")
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
error_response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": None,
|
||||||
|
"error": {
|
||||||
|
"code": -32700,
|
||||||
|
"message": f"Parse error: {e}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.write((json.dumps(error_response) + '\n').encode('utf-8'))
|
||||||
|
await writer.drain()
|
||||||
|
except Exception as e:
|
||||||
|
error_response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request.get("id") if 'request' in locals() else None,
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": f"Internal error: {e}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.write((json.dumps(error_response) + '\n').encode('utf-8'))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the daemon server."""
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# Close all client connections
|
||||||
|
for writer in list(self._clients):
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
# Close server
|
||||||
|
if self.server:
|
||||||
|
self.server.close()
|
||||||
|
await self.server.wait_closed()
|
||||||
|
|
||||||
|
# Clean up socket and PID files
|
||||||
|
if self.socket_path.exists():
|
||||||
|
self.socket_path.unlink()
|
||||||
|
|
||||||
|
pid_file = self.socket_path.with_suffix('.pid')
|
||||||
|
if pid_file.exists():
|
||||||
|
pid_file.unlink()
|
||||||
|
|
||||||
|
# Close browser
|
||||||
|
await self.browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
class MCPBrowserClient:
|
||||||
|
"""Client for connecting to MCP Browser daemon."""
|
||||||
|
|
||||||
|
def __init__(self, socket_path: Path):
|
||||||
|
self.socket_path = socket_path
|
||||||
|
self.reader: Optional[asyncio.StreamReader] = None
|
||||||
|
self.writer: Optional[asyncio.StreamWriter] = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Connect to the daemon."""
|
||||||
|
if not self.socket_path.exists():
|
||||||
|
raise ConnectionError(f"Daemon socket not found: {self.socket_path}")
|
||||||
|
|
||||||
|
# Check if daemon is alive
|
||||||
|
pid_file = self.socket_path.with_suffix('.pid')
|
||||||
|
if pid_file.exists():
|
||||||
|
try:
|
||||||
|
pid = int(pid_file.read_text().strip())
|
||||||
|
if not psutil.pid_exists(pid):
|
||||||
|
# Stale socket/PID file
|
||||||
|
self.socket_path.unlink()
|
||||||
|
pid_file.unlink()
|
||||||
|
raise ConnectionError("Daemon not running (stale PID file)")
|
||||||
|
except (ValueError, psutil.Error):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Connect to socket
|
||||||
|
self.reader, self.writer = await asyncio.open_unix_connection(str(self.socket_path))
|
||||||
|
|
||||||
|
async def call(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Send a JSON-RPC request and get response."""
|
||||||
|
if not self.writer:
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
# Send request
|
||||||
|
request_str = json.dumps(request) + '\n'
|
||||||
|
self.writer.write(request_str.encode('utf-8'))
|
||||||
|
await self.writer.drain()
|
||||||
|
|
||||||
|
# Read response
|
||||||
|
response_line = await self.reader.readline()
|
||||||
|
if not response_line:
|
||||||
|
raise ConnectionError("Connection closed by daemon")
|
||||||
|
|
||||||
|
return json.loads(response_line.decode('utf-8'))
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the connection."""
|
||||||
|
if self.writer:
|
||||||
|
self.writer.close()
|
||||||
|
await self.writer.wait_closed()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry."""
|
||||||
|
await self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_socket_path(server_name: Optional[str] = None) -> Path:
|
||||||
|
"""Get the socket path for a given server name."""
|
||||||
|
runtime_dir = os.environ.get('XDG_RUNTIME_DIR')
|
||||||
|
if not runtime_dir:
|
||||||
|
runtime_dir = f"/tmp/mcp-browser-{os.getuid()}"
|
||||||
|
|
||||||
|
socket_name = f"mcp-browser-{server_name}.sock" if server_name else "mcp-browser.sock"
|
||||||
|
return Path(runtime_dir) / socket_name
|
||||||
|
|
||||||
|
|
||||||
|
def is_daemon_running(socket_path: Path) -> bool:
|
||||||
|
"""Check if a daemon is running for the given socket."""
|
||||||
|
if not socket_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
pid_file = socket_path.with_suffix('.pid')
|
||||||
|
if not pid_file.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(pid_file.read_text().strip())
|
||||||
|
return psutil.pid_exists(pid)
|
||||||
|
except (ValueError, psutil.Error):
|
||||||
|
return False
|
||||||
|
|
@ -16,6 +16,7 @@ from .multi_server import MultiServerManager
|
||||||
from .registry import ToolRegistry
|
from .registry import ToolRegistry
|
||||||
from .filter import MessageFilter, VirtualToolHandler
|
from .filter import MessageFilter, VirtualToolHandler
|
||||||
from .buffer import JsonRpcBuffer
|
from .buffer import JsonRpcBuffer
|
||||||
|
from .utils import debug_print, debug_json
|
||||||
|
|
||||||
|
|
||||||
class MCPBrowser:
|
class MCPBrowser:
|
||||||
|
|
@ -166,6 +167,52 @@ class MCPBrowser:
|
||||||
"error": {"code": -32603, "message": str(e)}
|
"error": {"code": -32603, "message": str(e)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if we have a server
|
||||||
|
if not self.server:
|
||||||
|
# In builtin-only mode, try to route to multi-server
|
||||||
|
if self.multi_server:
|
||||||
|
# Try to route based on method
|
||||||
|
if jsonrpc_object.get("method") == "tools/list":
|
||||||
|
# Apply filter to multi-server tools
|
||||||
|
tools = await self.multi_server.get_all_tools()
|
||||||
|
filtered_tools = self.filter.filter_outgoing({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {"tools": tools}
|
||||||
|
})
|
||||||
|
return filtered_tools
|
||||||
|
elif jsonrpc_object.get("method") == "tools/call":
|
||||||
|
# Route tool call to multi-server
|
||||||
|
tool_name = jsonrpc_object.get("params", {}).get("name")
|
||||||
|
args = jsonrpc_object.get("params", {}).get("arguments", {})
|
||||||
|
try:
|
||||||
|
result = await self.multi_server.route_tool_call(tool_name, args)
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": result
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {"code": -32603, "message": str(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
# No server available
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": "No MCP server available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Debug output
|
||||||
|
if self.config.debug:
|
||||||
|
debug_json("MCP Browser sending", jsonrpc_object)
|
||||||
|
|
||||||
# Create future for response
|
# Create future for response
|
||||||
future = asyncio.Future()
|
future = asyncio.Future()
|
||||||
self._response_buffer[request_id] = future
|
self._response_buffer[request_id] = future
|
||||||
|
|
@ -176,6 +223,8 @@ class MCPBrowser:
|
||||||
# Wait for response
|
# Wait for response
|
||||||
try:
|
try:
|
||||||
response = await asyncio.wait_for(future, timeout=self.config.timeout)
|
response = await asyncio.wait_for(future, timeout=self.config.timeout)
|
||||||
|
if self.config.debug:
|
||||||
|
debug_json("MCP Browser received", response)
|
||||||
return response
|
return response
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
del self._response_buffer[request_id]
|
del self._response_buffer[request_id]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from .buffer import JsonRpcBuffer
|
from .buffer import JsonRpcBuffer
|
||||||
from .config import MCPServerConfig
|
from .config import MCPServerConfig
|
||||||
|
from .utils import debug_print, debug_json
|
||||||
|
|
||||||
|
|
||||||
class MCPServer:
|
class MCPServer:
|
||||||
|
|
@ -46,7 +47,7 @@ class MCPServer:
|
||||||
cmd = self.config.command + self.config.args
|
cmd = self.config.command + self.config.args
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
print(f"Starting MCP server: {' '.join(cmd)}")
|
debug_print(f"Starting MCP server: {' '.join(cmd)}")
|
||||||
|
|
||||||
# Start process
|
# Start process
|
||||||
self.process = subprocess.Popen(
|
self.process = subprocess.Popen(
|
||||||
|
|
@ -116,7 +117,7 @@ class MCPServer:
|
||||||
self.process.stdin.flush()
|
self.process.stdin.flush()
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
print(f"Sent: {request_str.strip()}")
|
debug_print(f"Sent: {request_str.strip()}")
|
||||||
|
|
||||||
# Wait for response
|
# Wait for response
|
||||||
try:
|
try:
|
||||||
|
|
@ -134,6 +135,9 @@ class MCPServer:
|
||||||
if not message.endswith('\n'):
|
if not message.endswith('\n'):
|
||||||
message += '\n'
|
message += '\n'
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
debug_print(f"MCP Server sending: {message.strip()}")
|
||||||
|
|
||||||
self.process.stdin.write(message)
|
self.process.stdin.write(message)
|
||||||
self.process.stdin.flush()
|
self.process.stdin.flush()
|
||||||
|
|
||||||
|
|
@ -155,7 +159,7 @@ class MCPServer:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.debug:
|
if self.debug:
|
||||||
print(f"Error reading stdout: {e}")
|
debug_print(f"Error reading stdout: {e}")
|
||||||
break
|
break
|
||||||
|
|
||||||
async def _read_stderr(self):
|
async def _read_stderr(self):
|
||||||
|
|
@ -166,7 +170,7 @@ class MCPServer:
|
||||||
if not line:
|
if not line:
|
||||||
break
|
break
|
||||||
|
|
||||||
print(f"MCP stderr: {line.strip()}")
|
debug_print(f"MCP stderr: {line.strip()}")
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
break
|
break
|
||||||
|
|
@ -174,7 +178,7 @@ class MCPServer:
|
||||||
async def _handle_message(self, message: dict):
|
async def _handle_message(self, message: dict):
|
||||||
"""Handle an incoming JSON-RPC message."""
|
"""Handle an incoming JSON-RPC message."""
|
||||||
if self.debug:
|
if self.debug:
|
||||||
print(f"Received: {json.dumps(message)}")
|
debug_json("Received", message)
|
||||||
|
|
||||||
# Check if it's a response to a pending request
|
# Check if it's a response to a pending request
|
||||||
msg_id = message.get("id")
|
msg_id = message.get("id")
|
||||||
|
|
@ -192,4 +196,4 @@ class MCPServer:
|
||||||
handler(message)
|
handler(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.debug:
|
if self.debug:
|
||||||
print(f"Handler error: {e}")
|
debug_print(f"Handler error: {e}")
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""
|
||||||
|
Utility functions for MCP Browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def debug_print(message: str):
|
||||||
|
"""Print debug message to stderr."""
|
||||||
|
print(message, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def debug_json(label: str, data: Any):
|
||||||
|
"""Print JSON data to stderr for debugging."""
|
||||||
|
import json
|
||||||
|
print(f"{label}: {json.dumps(data)}", file=sys.stderr, flush=True)
|
||||||
Loading…
Reference in New Issue