=== NEW SERVERS ADDED (7) === - servers/closebot — 119 tools, 14 modules, 4,656 lines TS (Stage 7) - servers/google-console — Google Search Console MCP (Stage 7) - servers/meta-ads — Meta/Facebook Ads MCP (Stage 8) - servers/twilio — Twilio communications MCP (Stage 8) - servers/competitor-research — Competitive intel MCP (Stage 6) - servers/n8n-apps — n8n workflow MCP apps (Stage 6) - servers/reonomy — Commercial real estate MCP (Stage 1) === FACTORY INFRASTRUCTURE ADDED === - infra/factory-tools — mcp-jest, mcp-validator, mcp-add, MCP Inspector - 60 test configs, 702 auto-generated test cases - All 30 servers score 100/100 protocol compliance - infra/command-center — Pipeline state, operator playbook, dashboard config - infra/factory-reviews — Automated eval reports === DOCS ADDED === - docs/MCP-FACTORY.md — Factory overview - docs/reports/ — 5 pipeline evaluation reports - docs/research/ — Browser MCP research === RULES ESTABLISHED === - CONTRIBUTING.md — All MCP work MUST go in this repo - README.md — Full inventory of 37 servers + infra docs - .gitignore — Updated for Python venvs TOTAL: 37 MCP servers + full factory pipeline in one repo. This is now the single source of truth for all MCP work.
250 lines
8.0 KiB
Python
250 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""
|
|
STDIO server adapter for MCP testing.
|
|
|
|
This module defines a server adapter that starts a subprocess and
|
|
communicates with it over standard input/output using JSON-RPC.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
from typing import Dict, Any, List, Optional, Tuple, Union
|
|
|
|
from ..transports.stdio import StdioTransportAdapter
|
|
from .base import MCPServerAdapter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class StdioServerAdapter(MCPServerAdapter):
|
|
"""
|
|
Adapter for MCP servers that communicate over standard input/output.
|
|
|
|
This adapter starts a server as a subprocess and communicates with it
|
|
using the STDIO transport.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
server_command: str,
|
|
protocol_version: str,
|
|
debug: bool = False,
|
|
env: Optional[Dict[str, str]] = None,
|
|
):
|
|
"""
|
|
Initialize a STDIO server adapter.
|
|
|
|
Args:
|
|
server_command: The command to start the server
|
|
protocol_version: The MCP protocol version to use
|
|
debug: Whether to enable debug logging
|
|
env: Additional environment variables to set when running the server
|
|
"""
|
|
super().__init__(protocol_version, debug)
|
|
self.server_command = server_command
|
|
self.env = env or {}
|
|
self.process = None
|
|
self.transport = None
|
|
|
|
async def start(self) -> bool:
|
|
"""
|
|
Start the server as a subprocess.
|
|
|
|
Returns:
|
|
True if started successfully, False otherwise
|
|
"""
|
|
if self.process is not None:
|
|
logger.warning("Server is already running")
|
|
return True
|
|
|
|
# Create environment for the subprocess
|
|
env = os.environ.copy()
|
|
env.update(self.env)
|
|
|
|
if self.debug:
|
|
logger.debug(f"Starting server with command: {self.server_command}")
|
|
logger.debug(f"Environment: {env}")
|
|
|
|
try:
|
|
# Start the server process
|
|
args = shlex.split(self.server_command)
|
|
self.process = subprocess.Popen(
|
|
args,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env=env,
|
|
text=False, # We want binary I/O
|
|
)
|
|
|
|
# Create a transport adapter for communicating with the server
|
|
self.transport = StdioTransportAdapter(
|
|
self.process.stdin,
|
|
self.process.stdout,
|
|
self.debug,
|
|
)
|
|
|
|
# Start a background task to log stderr output
|
|
if self.debug:
|
|
asyncio.create_task(self._log_stderr())
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to start server: {str(e)}")
|
|
return False
|
|
|
|
async def stop(self) -> bool:
|
|
"""
|
|
Stop the server.
|
|
|
|
Returns:
|
|
True if stopped successfully, False otherwise
|
|
"""
|
|
if self.process is None:
|
|
logger.warning("Server is not running")
|
|
return True
|
|
|
|
try:
|
|
if self.debug:
|
|
logger.debug("Stopping server")
|
|
|
|
# Try to send a shutdown request first
|
|
await self.shutdown()
|
|
|
|
# Close the transport
|
|
if self.transport:
|
|
await self.transport.close()
|
|
self.transport = None
|
|
|
|
# If the process is still running, terminate it
|
|
if self.process.poll() is None:
|
|
logger.debug("Server still running, sending SIGTERM")
|
|
self.process.terminate()
|
|
|
|
# Wait for the process to terminate
|
|
try:
|
|
self.process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("Server didn't terminate, sending SIGKILL")
|
|
self.process.kill()
|
|
|
|
# Clean up
|
|
self.process = None
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to stop server: {str(e)}")
|
|
return False
|
|
|
|
async def send_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
"""
|
|
Send a request to the server and wait for a response.
|
|
|
|
Args:
|
|
method: The JSON-RPC method name
|
|
params: The method parameters
|
|
|
|
Returns:
|
|
The server's response
|
|
|
|
Raises:
|
|
RuntimeError: If the server is not started or the request fails
|
|
"""
|
|
if self.process is None or self.transport is None:
|
|
raise RuntimeError("Server is not running")
|
|
|
|
if params is None:
|
|
params = {}
|
|
|
|
request_id = self._get_next_request_id()
|
|
|
|
request = {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
|
|
if self.debug:
|
|
logger.debug(f"Sending request: {request}")
|
|
|
|
try:
|
|
await self.transport.send_message(request)
|
|
response = await self.transport.receive_message()
|
|
|
|
if self.debug:
|
|
logger.debug(f"Received response: {response}")
|
|
|
|
if not isinstance(response, dict):
|
|
raise RuntimeError(f"Expected dict response, got {type(response)}")
|
|
|
|
if "id" not in response or response["id"] != request_id:
|
|
raise RuntimeError(f"Response ID mismatch: expected {request_id}, got {response.get('id')}")
|
|
|
|
return response
|
|
except Exception as e:
|
|
logger.error(f"Failed to send request: {str(e)}")
|
|
raise RuntimeError(f"Failed to send request: {str(e)}")
|
|
|
|
async def send_notification(self, method: str, params: Dict[str, Any] = None) -> None:
|
|
"""
|
|
Send a notification to the server (no response expected).
|
|
|
|
Args:
|
|
method: The JSON-RPC method name
|
|
params: The method parameters
|
|
|
|
Raises:
|
|
RuntimeError: If the server is not started or the notification fails
|
|
"""
|
|
if self.process is None or self.transport is None:
|
|
raise RuntimeError("Server is not running")
|
|
|
|
if params is None:
|
|
params = {}
|
|
|
|
notification = {
|
|
"jsonrpc": "2.0",
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
|
|
if self.debug:
|
|
logger.debug(f"Sending notification: {notification}")
|
|
|
|
try:
|
|
await self.transport.send_message(notification)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send notification: {str(e)}")
|
|
raise RuntimeError(f"Failed to send notification: {str(e)}")
|
|
|
|
async def _log_stderr(self) -> None:
|
|
"""
|
|
Log the stderr output from the server process.
|
|
"""
|
|
if self.process is None or self.process.stderr is None:
|
|
return
|
|
|
|
while True:
|
|
try:
|
|
line = await asyncio.get_event_loop().run_in_executor(
|
|
None, self.process.stderr.readline
|
|
)
|
|
|
|
if not line:
|
|
break
|
|
|
|
try:
|
|
stderr_line = line.decode('utf-8').rstrip()
|
|
logger.debug(f"Server stderr: {stderr_line}")
|
|
except UnicodeDecodeError:
|
|
logger.debug(f"Server stderr (binary): {line}")
|
|
except Exception as e:
|
|
logger.error(f"Error reading stderr: {str(e)}")
|
|
break |