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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict, Any
|
||||
import yaml
|
||||
|
||||
from .proxy import MCPBrowser
|
||||
from .config import ConfigLoader
|
||||
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):
|
||||
|
|
@ -161,6 +302,130 @@ async def test_server_connection(browser: MCPBrowser, server_name: Optional[str]
|
|||
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):
|
||||
"""Run MCP Browser as an MCP server (stdin/stdout)."""
|
||||
import sys
|
||||
|
|
@ -193,16 +458,116 @@ async def run_server_mode(browser: MCPBrowser):
|
|||
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():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MCP Browser - 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:
|
||||
|
|
@ -217,7 +582,7 @@ Environment:
|
|||
)
|
||||
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"],
|
||||
parser.add_argument("--mode", choices=["interactive", "server", "daemon"],
|
||||
default="interactive", help="Operating mode (default: interactive)")
|
||||
parser.add_argument("--no-sparse", action="store_true",
|
||||
help="Disable sparse mode (show all tools)")
|
||||
|
|
@ -231,6 +596,49 @@ Environment:
|
|||
help="Test connection to specified server")
|
||||
parser.add_argument("--debug", action="store_true",
|
||||
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()
|
||||
|
||||
|
|
@ -243,6 +651,24 @@ Environment:
|
|||
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
|
||||
browser = MCPBrowser(
|
||||
|
|
@ -258,7 +684,22 @@ Environment:
|
|||
|
||||
# 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))
|
||||
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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .filter import MessageFilter, VirtualToolHandler
|
||||
from .buffer import JsonRpcBuffer
|
||||
from .utils import debug_print, debug_json
|
||||
|
||||
|
||||
class MCPBrowser:
|
||||
|
|
@ -166,6 +167,52 @@ class MCPBrowser:
|
|||
"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
|
||||
future = asyncio.Future()
|
||||
self._response_buffer[request_id] = future
|
||||
|
|
@ -176,6 +223,8 @@ class MCPBrowser:
|
|||
# Wait for response
|
||||
try:
|
||||
response = await asyncio.wait_for(future, timeout=self.config.timeout)
|
||||
if self.config.debug:
|
||||
debug_json("MCP Browser received", response)
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
del self._response_buffer[request_id]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from pathlib import Path
|
|||
|
||||
from .buffer import JsonRpcBuffer
|
||||
from .config import MCPServerConfig
|
||||
from .utils import debug_print, debug_json
|
||||
|
||||
|
||||
class MCPServer:
|
||||
|
|
@ -46,7 +47,7 @@ class MCPServer:
|
|||
cmd = self.config.command + self.config.args
|
||||
|
||||
if self.debug:
|
||||
print(f"Starting MCP server: {' '.join(cmd)}")
|
||||
debug_print(f"Starting MCP server: {' '.join(cmd)}")
|
||||
|
||||
# Start process
|
||||
self.process = subprocess.Popen(
|
||||
|
|
@ -116,7 +117,7 @@ class MCPServer:
|
|||
self.process.stdin.flush()
|
||||
|
||||
if self.debug:
|
||||
print(f"Sent: {request_str.strip()}")
|
||||
debug_print(f"Sent: {request_str.strip()}")
|
||||
|
||||
# Wait for response
|
||||
try:
|
||||
|
|
@ -134,6 +135,9 @@ class MCPServer:
|
|||
if not message.endswith('\n'):
|
||||
message += '\n'
|
||||
|
||||
if self.debug:
|
||||
debug_print(f"MCP Server sending: {message.strip()}")
|
||||
|
||||
self.process.stdin.write(message)
|
||||
self.process.stdin.flush()
|
||||
|
||||
|
|
@ -155,7 +159,7 @@ class MCPServer:
|
|||
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
print(f"Error reading stdout: {e}")
|
||||
debug_print(f"Error reading stdout: {e}")
|
||||
break
|
||||
|
||||
async def _read_stderr(self):
|
||||
|
|
@ -166,7 +170,7 @@ class MCPServer:
|
|||
if not line:
|
||||
break
|
||||
|
||||
print(f"MCP stderr: {line.strip()}")
|
||||
debug_print(f"MCP stderr: {line.strip()}")
|
||||
|
||||
except Exception:
|
||||
break
|
||||
|
|
@ -174,7 +178,7 @@ class MCPServer:
|
|||
async def _handle_message(self, message: dict):
|
||||
"""Handle an incoming JSON-RPC message."""
|
||||
if self.debug:
|
||||
print(f"Received: {json.dumps(message)}")
|
||||
debug_json("Received", message)
|
||||
|
||||
# Check if it's a response to a pending request
|
||||
msg_id = message.get("id")
|
||||
|
|
@ -192,4 +196,4 @@ class MCPServer:
|
|||
handler(message)
|
||||
except Exception as e:
|
||||
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