fix: include runtime package for mcp-exec command (closes #66)

The runtime package was gitignored but is required for:
- mcp-exec command
- mcp-generate command
- mcp-discover command

Whitelisted opc/src/runtime/ in .gitignore.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parcadei
2026-01-12 14:18:12 +00:00
parent d447d24829
commit 6b7e73d466
14 changed files with 3384 additions and 1 deletions
+2 -1
View File
@@ -152,7 +152,8 @@ opc/scripts/tldr_fluid_cache_model.py
opc/.github/
opc/tests/
opc/docs/
opc/src/
opc/src/*
!opc/src/runtime/
opc/spec/
opc/packages/
opc/migrations/
View File
+148
View File
@@ -0,0 +1,148 @@
"""Configuration models using Pydantic for MCP Code Execution runtime.
This module defines the configuration structure for MCP servers and provides
validation using Pydantic models.
"""
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
class ServerConfig(BaseModel):
"""Configuration for a single MCP server.
Supports three transport types:
- stdio: Process-based (command, args, env)
- sse: Server-Sent Events (url, headers)
- http: Streamable HTTP (url, headers)
Attributes:
type: Transport type ('stdio', 'sse', or 'http')
command: The command to execute (stdio only)
args: List of arguments (stdio only)
env: Environment variables (stdio only)
url: Endpoint URL (sse/http only)
headers: HTTP headers (sse/http only)
disabled: Whether this server should be skipped
"""
type: Literal["stdio", "sse", "http"] = Field(default="stdio", description="Transport type")
# stdio fields
command: str | None = Field(default=None, description="Command to execute (stdio only)")
args: list[str] = Field(default_factory=list, description="Arguments for command (stdio)")
env: dict[str, str] | None = Field(default=None, description="Environment variables (stdio)")
# sse/http fields
url: str | None = Field(default=None, description="Endpoint URL (sse/http only)")
headers: dict[str, str] | None = Field(default=None, description="HTTP headers (sse/http only)")
# common fields
disabled: bool = Field(default=False, description="Whether to skip this server")
@model_validator(mode="after")
def validate_transport_fields(self) -> "ServerConfig":
"""Validate fields based on transport type."""
if self.type == "stdio":
if not self.command:
raise ValueError("stdio servers require 'command' field")
if self.command and not self.command.strip():
raise ValueError("Command cannot be empty")
# Ensure args is a list
if self.args is None:
self.args = []
elif self.type in ("sse", "http"):
if not self.url:
raise ValueError(f"{self.type} servers require 'url' field")
if self.url and not self.url.strip():
raise ValueError("URL cannot be empty")
else:
raise ValueError(f"Invalid transport type: {self.type}")
return self
class McpConfig(BaseModel):
"""Root configuration for all MCP servers.
Attributes:
mcpServers: Dictionary mapping server names to their configurations
"""
mcpServers: dict[str, ServerConfig] = Field(
..., description="Mapping of server names to configurations"
)
@field_validator("mcpServers")
@classmethod
def servers_not_empty(cls, v: dict[str, ServerConfig]) -> dict[str, ServerConfig]:
"""Validate that at least one server is configured."""
if not v:
raise ValueError("At least one MCP server must be configured")
return v
def get_enabled_servers(self) -> dict[str, ServerConfig]:
"""Return only enabled servers.
Returns:
Dictionary of server names to configurations for enabled servers only
"""
return {name: config for name, config in self.mcpServers.items() if not config.disabled}
def get_server(self, name: str) -> ServerConfig | None:
"""Get configuration for a specific server by name.
Args:
name: Server name to look up
Returns:
ServerConfig if found, None otherwise
"""
return self.mcpServers.get(name)
@classmethod
def from_dict(cls, config_dict: dict[str, Any]) -> "McpConfig":
"""Create McpConfig from a dictionary.
Args:
config_dict: Dictionary containing configuration data
Returns:
Validated McpConfig instance
Raises:
ValidationError: If configuration is invalid
"""
return cls.model_validate(config_dict)
@classmethod
def from_json(cls, json_str: str) -> "McpConfig":
"""Create McpConfig from a JSON string.
Args:
json_str: JSON string containing configuration
Returns:
Validated McpConfig instance
Raises:
ValidationError: If configuration is invalid
JSONDecodeError: If JSON is malformed
"""
return cls.model_validate_json(json_str)
def merge(self, other: "McpConfig") -> "McpConfig":
"""Merge another config into this one, with other taking precedence.
This is used to merge global config with project config, where
project config overrides global for servers with the same name.
Args:
other: Config to merge (takes precedence on conflicts)
Returns:
New McpConfig with merged servers
"""
merged_servers = {**self.mcpServers, **other.mcpServers}
return McpConfig(mcpServers=merged_servers)
+279
View File
@@ -0,0 +1,279 @@
"""
Generate Pydantic models from actual API responses.
This module discovers Pydantic models by executing safe tools and inferring
types from their responses. Useful for servers that don't provide output schemas.
Typical workflow:
1. Run: uv run mcp-generate-discovery
(generates discovery_config.json from mcp_config.json using Claude LLM)
2. Review and edit discovery_config.json as needed
3. Run: uv run mcp-discover
(executes safe tools and infers schemas)
4. Review generated schemas in servers/{server}/discovered_types.py
"""
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
import aiofiles
from .exceptions import ToolExecutionError
from .mcp_client import McpClientManager
from .schema_inference import (
infer_pydantic_model_from_response,
)
logger = logging.getLogger("mcp_execution.discover_schemas")
def find_project_root(start_dir: Path) -> Path:
"""Find project root by looking for .git directory.
Walks up from start_dir until finding .git or hitting filesystem root.
This ensures we find the right directory even if cwd is a subdirectory.
Args:
start_dir: Directory to start searching from
Returns:
Path to project root (directory containing .git), or start_dir if not found
"""
current = start_dir.resolve()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
return start_dir # Fallback to original if no .git found
async def execute_safe_tool(
manager: McpClientManager,
server_name: str,
tool_name: str,
params: dict[str, Any],
) -> Any:
"""
Execute a tool safely (read-only operations only).
Args:
manager: MCP client manager
server_name: Name of server
tool_name: Name of tool
params: Tool parameters
Returns:
Response from tool
Raises:
ToolExecutionError: If tool fails or has side effects
"""
tool_id = f"{server_name}__{tool_name}"
logger.debug(f"Executing safe tool: {tool_id} with params: {params}")
try:
result = await manager.call_tool(tool_id, params)
# Defensive unwrapping
unwrapped = getattr(result, "value", result)
return unwrapped
except Exception as e:
raise ToolExecutionError(f"Failed to execute safe tool {tool_id}: {e}") from e
async def discover_server_schemas(
manager: McpClientManager,
server_name: str,
safe_tools_config: dict[str, dict[str, Any]],
) -> dict[str, str]:
"""
Discover Pydantic models for a single server's tools.
Args:
manager: MCP client manager
server_name: Name of server
safe_tools_config: Dict mapping tool name to sample params
Returns:
Dict mapping tool name to Pydantic model code
"""
logger.info(f"Discovering schemas for server: {server_name}")
discovered_models = {}
for tool_name, sample_params in safe_tools_config.items():
try:
logger.debug(f"Discovering schema for {server_name}.{tool_name}")
# Execute with sample parameters
response = await execute_safe_tool(manager, server_name, tool_name, sample_params)
# Generate Pydantic model from response
model_code = infer_pydantic_model_from_response(tool_name, response)
discovered_models[tool_name] = model_code
logger.debug(f"✓ Discovered schema for {tool_name}")
except Exception as e:
logger.warning(f"Failed to discover schema for {tool_name}: {e}")
# Continue with other tools
continue
return discovered_models
async def write_discovered_types(
server_name: str,
discovered_models: dict[str, str],
output_dir: Path,
) -> None:
"""
Write discovered Pydantic models to file.
Creates: servers/{server}/discovered_types.py
Args:
server_name: Name of server
discovered_models: Dict mapping tool name to model code
output_dir: Output directory (servers/)
"""
server_dir = output_dir / server_name
server_dir.mkdir(parents=True, exist_ok=True)
discovered_file = server_dir / "discovered_types.py"
# Build file content
lines = [
'"""',
f"Discovered Pydantic models for {server_name} server.",
"",
"WARNING: These models are inferred from actual API responses.",
"They may be incomplete or incorrect. Use with caution.",
"All fields are Optional for defensive coding.",
'"""',
"",
"from pydantic import BaseModel",
"from typing import Any, Dict, List, Optional",
"",
]
# Add all discovered models
for tool_name, model_code in discovered_models.items():
lines.append(model_code)
lines.append("")
content = "\n".join(lines)
async with aiofiles.open(discovered_file, "w") as f:
await f.write(content)
logger.info(f"Wrote discovered types to: {discovered_file}")
async def discover_schemas(config_path: Path | None = None) -> None:
"""
Main schema discovery orchestrator.
1. Load discovery_config.json
2. For each configured server:
a. Connect to server
b. Execute safe tools with sample params
c. Infer Pydantic models from responses
d. Write to servers/{server}/discovered_types.py
3. Log results
Args:
config_path: Path to discovery_config.json
"""
logger.info("Starting schema discovery...")
# Load discovery config
project_root = find_project_root(Path.cwd())
config_file = config_path or project_root / "discovery_config.json"
if not config_file.exists():
logger.warning(f"Discovery config not found: {config_file}. Skipping schema discovery.")
return
try:
async with aiofiles.open(config_file) as f:
content = await f.read()
discovery_config = json.loads(content)
except Exception as e:
logger.error(f"Failed to load discovery config: {e}")
return
# Initialize MCP client manager
manager = McpClientManager()
try:
await manager.initialize()
except Exception as e:
logger.error(f"Failed to initialize MCP client: {e}")
return
# Output directory
output_dir = Path(__file__).parent.parent.parent / "servers"
output_dir.mkdir(exist_ok=True)
# Discover schemas for each server
servers_config = discovery_config.get("servers", {})
# Log metadata if present (from mcp-generate-discovery)
metadata = discovery_config.get("metadata", {})
if metadata.get("generated"):
logger.info(
f"Using auto-generated config: "
f"{metadata.get('generated_count', 0)} tools, "
f"{metadata.get('skipped_count', 0)} skipped"
)
for server_name, server_config in servers_config.items():
try:
safe_tools_config = server_config.get("safeTools", {})
if not safe_tools_config:
logger.debug(f"No safe tools configured for {server_name}, skipping")
continue
# Discover schemas
discovered_models = await discover_server_schemas(
manager, server_name, safe_tools_config
)
if discovered_models:
# Write discovered types
await write_discovered_types(server_name, discovered_models, output_dir)
logger.info(f"✓ Discovered {len(discovered_models)} schemas for {server_name}")
else:
logger.warning(f"No schemas discovered for {server_name}")
except Exception as e:
logger.error(f"Failed to discover schemas for {server_name}: {e}")
continue
# Cleanup
try:
await manager.cleanup()
except Exception as e:
logger.error(f"Cleanup failed: {e}")
logger.info("Schema discovery complete!")
def main() -> None:
"""CLI entry point."""
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(message)s",
)
asyncio.run(discover_schemas())
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
"""Environment variable utilities for MCP config loading.
This module provides:
- .env file loading via python-dotenv
- ${VAR} and ${VAR:-default} expansion in config values
"""
import os
import re
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
# Pattern for ${VAR} or ${VAR:-default}
ENV_VAR_PATTERN = re.compile(r"\$\{([^}:]+)(?::-([^}]*))?\}")
def find_project_root(start_dir: Path) -> Path:
"""Find project root by looking for .git directory.
Walks up from start_dir until finding .git or hitting filesystem root.
This ensures we find the right directory even if cwd is a subdirectory.
Args:
start_dir: Directory to start searching from
Returns:
Path to project root (directory containing .git), or start_dir if not found
"""
current = start_dir.resolve()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
return start_dir # Fallback to original if no .git found
def expand_env_vars(value: str) -> str:
"""Expand environment variables in a string.
Supports:
- ${VAR} - expands to env var value or empty string
- ${VAR:-default} - expands to env var value or default
Args:
value: String potentially containing ${VAR} patterns
Returns:
String with all env vars expanded
"""
def replacer(match: re.Match[str]) -> str:
var_name = match.group(1)
default = match.group(2) # May be None
env_value = os.environ.get(var_name)
if env_value is not None:
return env_value
return default if default is not None else ""
return ENV_VAR_PATTERN.sub(replacer, value)
def expand_env_vars_in_config(config: Any) -> Any:
"""Recursively expand environment variables in a config structure.
Args:
config: Dict, list, string, or other value
Returns:
Same structure with all string values having env vars expanded
"""
if isinstance(config, dict):
return {key: expand_env_vars_in_config(value) for key, value in config.items()}
elif isinstance(config, list):
return [expand_env_vars_in_config(item) for item in config]
elif isinstance(config, str):
return expand_env_vars(config)
else:
return config
def load_project_env(start_path: Path | None = None) -> bool:
"""Load .env file from project root, with global fallback.
Searches for .env in:
1. Current directory or specified path
2. ~/.claude/.env (global fallback)
Does not override existing environment variables.
Args:
start_path: Directory to search for .env (default: cwd)
Returns:
True if .env was loaded, False otherwise
"""
search_path = start_path or find_project_root(Path.cwd())
env_file = search_path / ".env"
global_env = Path.home() / ".claude" / ".env"
loaded = False
# Load global .env first (lower priority)
if global_env.exists():
load_dotenv(global_env, override=False)
loaded = True
# Load local .env (higher priority, but doesn't override existing)
if env_file.exists():
load_dotenv(env_file, override=False)
loaded = True
return loaded
+67
View File
@@ -0,0 +1,67 @@
"""Custom exception classes for MCP Code Execution runtime.
This module defines the exception hierarchy for handling various error
conditions in the MCP execution environment.
"""
class McpExecutionError(Exception):
"""Base exception for all MCP execution errors."""
pass
class ServerConnectionError(McpExecutionError):
"""Raised when unable to connect to an MCP server.
This typically indicates issues with server availability, configuration,
or network connectivity.
"""
pass
class ToolNotFoundError(McpExecutionError):
"""Raised when a requested tool does not exist on any configured server.
This can occur if:
- The tool name is misspelled
- The server providing the tool is not configured
- The tool has been removed from the server
"""
pass
class ToolExecutionError(McpExecutionError):
"""Raised when tool execution fails.
This wraps errors that occur during the actual execution of a tool,
such as invalid parameters, permission issues, or internal tool errors.
"""
pass
class ConfigurationError(McpExecutionError):
"""Raised when there are issues with configuration files or settings.
This includes:
- Invalid JSON in mcp_config.json
- Missing required configuration fields
- Invalid configuration values
"""
pass
class SchemaValidationError(McpExecutionError):
"""Raised when schema validation fails.
This occurs when:
- Response data doesn't match expected schema
- Input parameters fail validation
- JSON Schema to Pydantic conversion fails
"""
pass
+774
View File
@@ -0,0 +1,774 @@
"""
Generate test parameters for MCP tools using Claude LLM.
This module uses Claude to generate reasonable test parameters from tool
inputSchemas, enabling automatic discovery configuration generation.
It also classifies tools by safety (SAFE/DANGEROUS/UNKNOWN) based on patterns
and descriptions.
"""
import argparse
import json
import logging
import re
import subprocess
from enum import Enum
from pathlib import Path
from typing import Any
try:
import anthropic
from anthropic.types import TextBlock
except ImportError:
anthropic = None # type: ignore[assignment]
TextBlock = None # type: ignore[assignment, misc]
logger = logging.getLogger("mcp_execution.generate_test_params")
class ToolSafety(str, Enum):
"""Safety classification for tools."""
SAFE = "safe"
DANGEROUS = "dangerous"
UNKNOWN = "unknown"
# Regex patterns for tool classification
SAFE_PATTERNS = [
r"^get_",
r"^list_",
r"^search_",
r"^describe_",
r"^fetch",
r"^read_",
r"^show_",
r"^view_",
r"^find_",
r"^query_",
]
DANGEROUS_PATTERNS = [
r"^delete_",
r"^remove_",
r"^drop_",
r"^destroy_",
r"^kill_",
r"^create_.*table",
r"^update_",
r"^write_",
r"^execute_",
r"^run_",
r"^modify_",
r"^set_",
r"^put_",
r"^post_",
]
SAFE_KEYWORDS = [
"get",
"list",
"read",
"fetch",
"search",
"query",
"show",
"view",
"find",
"describe",
]
DANGEROUS_KEYWORDS = [
"delete",
"remove",
"drop",
"destroy",
"kill",
"update",
"write",
"execute",
"modify",
"truncate",
]
def classify_tool(tool_name: str, description: str | None = None) -> ToolSafety:
"""
Classify a tool as SAFE, DANGEROUS, or UNKNOWN based on patterns and description.
Classification strategy:
1. Check description for dangerous keywords (overrides all else)
2. Check against explicit regex patterns
3. Fall back to description keywords
4. Default to UNKNOWN if no signals
Args:
tool_name: Name of the tool
description: Optional tool description
Returns:
ToolSafety classification
"""
# First priority: dangerous keywords in description override everything
if description:
desc_lower = description.lower()
if any(kw in desc_lower for kw in DANGEROUS_KEYWORDS):
return ToolSafety.DANGEROUS
# Check dangerous patterns (high priority)
if any(re.match(pattern, tool_name, re.IGNORECASE) for pattern in DANGEROUS_PATTERNS):
return ToolSafety.DANGEROUS
# Check safe patterns
if any(re.match(pattern, tool_name, re.IGNORECASE) for pattern in SAFE_PATTERNS):
return ToolSafety.SAFE
# Fall back to description safe keywords
if description:
desc_lower = description.lower()
if any(kw in desc_lower for kw in SAFE_KEYWORDS):
return ToolSafety.SAFE
return ToolSafety.UNKNOWN
def _load_prompt_template() -> str:
"""Load the prompt template from src/prompts/generate_test_params.txt."""
# Get the directory where this module is located
module_dir = Path(__file__).parent
# Navigate to src/prompts/generate_test_params.txt
template_path = module_dir.parent / "prompts" / "generate_test_params.txt"
try:
return template_path.read_text()
except FileNotFoundError:
logger.warning(f"Prompt template not found at {template_path}")
# Fallback to inline template
return """Generate minimal test parameters for this MCP tool.
Tool: {tool_name}
{description_line}
Input Schema:
```json
{schema_json}
```
Requirements:
- Return ONLY valid JSON that satisfies the schema
- Use minimal values: empty strings, 0, 1, [], {{}}
- Be conservative: avoid URLs, file paths, or special values unless required
- Ensure all required fields are present
- For arrays/objects, use minimal examples (1-2 items)
Return ONLY the JSON object, no explanation."""
def _generate_with_claude_code(
tool_name: str,
input_schema: dict[str, Any],
description: str | None = None,
) -> dict[str, Any] | None:
"""
Generate test parameters using Claude Code CLI via subprocess.
Args:
tool_name: Name of the tool
input_schema: JSON Schema for tool inputs
description: Optional tool description for context
Returns:
Dict of test parameters, or None if generation fails
"""
try:
# Load and format the prompt template
template = _load_prompt_template()
description_line = f"Description: {description}" if description else ""
schema_json = json.dumps(input_schema, indent=2)
prompt = template.format(
tool_name=tool_name,
description_line=description_line,
schema_json=schema_json,
)
# Run claude CLI command
result = subprocess.run(
["claude", "-p", prompt, "--dangerously-skip-permissions"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
logger.warning(f"Claude Code CLI failed for {tool_name}: {result.stderr}")
return None
# Extract JSON from response
response_text = result.stdout.strip()
# Handle markdown code blocks
if response_text.startswith("```"):
response_text = response_text.split("```")[1]
if response_text.startswith("json"):
response_text = response_text[4:]
response_text = response_text.strip()
params = json.loads(response_text)
if not isinstance(params, dict):
logger.warning(f"Generated params for {tool_name} is not a dict: {type(params)}")
return None
logger.debug(f"Generated params for {tool_name}: {params}")
return params
except subprocess.TimeoutExpired:
logger.warning(f"Claude Code CLI timed out for {tool_name}")
return None
except FileNotFoundError:
logger.warning("Claude Code CLI not found. Install from: https://docs.claude.com")
return None
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse generated params for {tool_name}: {e}")
return None
except Exception as e:
logger.warning(f"Unexpected error with Claude Code CLI for {tool_name}: {e}")
return None
def _generate_with_copilot_cli(
tool_name: str,
input_schema: dict[str, Any],
description: str | None = None,
) -> dict[str, Any] | None:
"""
Generate test parameters using GitHub Copilot CLI via subprocess.
Args:
tool_name: Name of the tool
input_schema: JSON Schema for tool inputs
description: Optional tool description for context
Returns:
Dict of test parameters, or None if generation fails
"""
try:
# Load and format the prompt template
template = _load_prompt_template()
description_line = f"Description: {description}" if description else ""
schema_json = json.dumps(input_schema, indent=2)
prompt = template.format(
tool_name=tool_name,
description_line=description_line,
schema_json=schema_json,
)
# Run copilot CLI command
result = subprocess.run(
["copilot", "-p", f"prompt {prompt}", "--allow-all-tools"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
logger.warning(f"Copilot CLI failed for {tool_name}: {result.stderr}")
return None
# Extract JSON from response
response_text = result.stdout.strip()
# Handle markdown code blocks
if response_text.startswith("```"):
response_text = response_text.split("```")[1]
if response_text.startswith("json"):
response_text = response_text[4:]
response_text = response_text.strip()
params = json.loads(response_text)
if not isinstance(params, dict):
logger.warning(f"Generated params for {tool_name} is not a dict: {type(params)}")
return None
logger.debug(f"Generated params for {tool_name}: {params}")
return params
except subprocess.TimeoutExpired:
logger.warning(f"Copilot CLI timed out for {tool_name}")
return None
except FileNotFoundError:
logger.warning("Copilot CLI not found. Please install GitHub Copilot CLI")
return None
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse generated params for {tool_name}: {e}")
return None
except Exception as e:
logger.warning(f"Unexpected error with Copilot CLI for {tool_name}: {e}")
return None
def generate_test_parameters(
tool_name: str,
input_schema: dict[str, Any],
description: str | None = None,
use_claude_api: bool = True,
use_claude_code: bool = False,
use_copilot_cli: bool = False,
) -> dict[str, Any] | None:
"""
Generate test parameters for a tool using Claude.
Uses Claude Haiku API, Claude Code CLI, or Copilot CLI to generate minimal but valid
test parameters that satisfy the tool's inputSchema. Returns None on any
error (safe fallback).
Args:
tool_name: Name of the tool
input_schema: JSON Schema for tool inputs
description: Optional tool description for context
use_claude_api: If False, skip Claude API and return None (default: True)
use_claude_code: If True, use Claude Code CLI instead of API (default: False)
use_copilot_cli: If True, use Copilot CLI instead of API (default: False)
Returns:
Dict of test parameters, or None if generation fails
Example:
>>> schema = {
... "type": "object",
... "properties": {
... "repo_path": {"type": "string"},
... "max_count": {"type": "integer"}
... },
... "required": ["repo_path"]
... }
>>> params = generate_test_parameters("git_log", schema)
>>> # Returns: {"repo_path": ".", "max_count": 1}
"""
# CLI tools take precedence over API: Copilot > Claude Code > API
if use_copilot_cli:
logger.debug(f"Using Copilot CLI for {tool_name}")
return _generate_with_copilot_cli(tool_name, input_schema, description)
if use_claude_code:
logger.debug(f"Using Claude Code CLI for {tool_name}")
return _generate_with_claude_code(tool_name, input_schema, description)
if not use_claude_api:
logger.debug(f"Skipping Claude API for {tool_name} (--claude-api disabled)")
return None
if anthropic is None:
logger.warning("anthropic library not installed. Install with: uv pip install anthropic")
return None
try:
client = anthropic.Anthropic()
# Load and format the prompt template
template = _load_prompt_template()
description_line = f"Description: {description}" if description else ""
schema_json = json.dumps(input_schema, indent=2)
prompt = template.format(
tool_name=tool_name,
description_line=description_line,
schema_json=schema_json,
)
message = client.messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
# Extract JSON from response
response_text = ""
if message.content:
first_block = message.content[0]
if hasattr(first_block, "text"):
response_text = first_block.text
response_text = response_text.strip()
# Handle markdown code blocks
if response_text.startswith("```"):
response_text = response_text.split("```")[1]
if response_text.startswith("json"):
response_text = response_text[4:]
response_text = response_text.strip()
params = json.loads(response_text)
if not isinstance(params, dict):
logger.warning(f"Generated params for {tool_name} is not a dict: {type(params)}")
return None
logger.debug(f"Generated params for {tool_name}: {params}")
return params
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse generated params for {tool_name}: {e}")
return None
except anthropic.APIError as e:
logger.warning(f"Anthropic API error generating params for {tool_name}: {e}")
return None
except Exception as e:
logger.warning(f"Unexpected error generating params for {tool_name}: {e}")
return None
def build_discovery_config(
servers_tools: dict[str, list[dict[str, Any]]],
skip_dangerous: bool = True,
use_claude_api: bool = True,
use_claude_code: bool = False,
use_copilot_cli: bool = False,
) -> dict[str, Any]:
"""
Build a discovery config from servers and their tools.
For each tool, generates test parameters and classifies by safety.
Tools marked as DANGEROUS are excluded by default.
Args:
servers_tools: Dict mapping server names to list of tool definitions
Each tool should have: name, inputSchema, description
skip_dangerous: If True, exclude DANGEROUS tools from config (default: True)
use_claude_api: If False, skip Claude API calls for parameter generation (default: True)
use_claude_code: If True, use Claude Code CLI instead of API (default: False)
use_copilot_cli: If True, use Copilot CLI instead of API (default: False)
Returns:
Dictionary suitable for writing to discovery_config.json
Example output:
{
"servers": {
"git": {
"safeTools": {
"git_log": {"repo_path": ".", "max_count": 1},
"git_status": {"repo_path": "."}
}
}
},
"metadata": {
"generated": true,
"tools_skipped": {"dangerous": [...], "unknown": [...]},
"generated_count": 5,
"skipped_count": 2
}
}
"""
config: dict[str, Any] = {"servers": {}}
tools_skipped: dict[str, list[str]] = {"dangerous": [], "unknown": []}
metadata: dict[str, Any] = {
"generated": True,
"tools_skipped": tools_skipped,
"generated_count": 0,
"skipped_count": 0,
}
for server_name, tools in servers_tools.items():
safe_tools_config: dict[str, dict[str, Any]] = {}
generated_count = 0
skipped_count = 0
for tool in tools:
tool_name = tool.get("name", "")
if not tool_name:
continue
description = tool.get("description", "")
input_schema = tool.get("inputSchema", {})
# Classify tool
safety = classify_tool(tool_name, description)
# Skip dangerous tools if requested
if skip_dangerous and safety == ToolSafety.DANGEROUS:
tools_skipped["dangerous"].append(tool_name)
skipped_count += 1
continue
# Skip unknown tools (require manual config)
if safety == ToolSafety.UNKNOWN:
tools_skipped["unknown"].append(tool_name)
skipped_count += 1
continue
# Generate test parameters
params = generate_test_parameters(
tool_name,
input_schema,
description,
use_claude_api,
use_claude_code,
use_copilot_cli,
)
if params is None:
logger.warning(f"Failed to generate params for {server_name}.{tool_name}")
tools_skipped["unknown"].append(tool_name)
skipped_count += 1
continue
safe_tools_config[tool_name] = params
generated_count += 1
if safe_tools_config:
config["servers"][server_name] = {"safeTools": safe_tools_config}
metadata["generated_count"] = metadata["generated_count"] + generated_count
metadata["skipped_count"] = metadata["skipped_count"] + skipped_count
config["metadata"] = metadata
return config
def print_discovery_summary(config: dict[str, Any]) -> None:
"""
Print a human-readable summary of generated discovery config.
Args:
config: Discovery configuration dictionary
"""
metadata = config.get("metadata", {})
servers = config.get("servers", {})
print("\n" + "=" * 60)
print("DISCOVERY CONFIG GENERATION SUMMARY")
print("=" * 60)
print(f"\n✓ Generated: {metadata.get('generated_count', 0)} tools")
print(f"⊗ Skipped: {metadata.get('skipped_count', 0)} tools")
skipped = metadata.get("tools_skipped", {})
if skipped.get("dangerous"):
print(f" - Dangerous: {', '.join(skipped['dangerous'])}")
if skipped.get("unknown"):
print(f" - Unknown: {', '.join(skipped['unknown'])}")
print("\nServers configured:")
for server_name, server_config in servers.items():
safe_tools = server_config.get("safeTools", {})
print(f" {server_name}: {len(safe_tools)} safe tools")
for tool_name in sorted(safe_tools.keys()):
print(f" - {tool_name}")
print("\nNext steps:")
print(" 1. Review discovery_config.json")
print(" 2. Add or remove tools as needed")
print(" 3. Run: uv run mcp-discover")
print("=" * 60 + "\n")
async def generate_discovery_config_file(
mcp_config_path: str | None = None,
output_path: str | None = None,
skip_dangerous: bool = True,
use_claude_api: bool = True,
use_claude_code: bool = False,
use_copilot_cli: bool = False,
) -> None:
"""
Main entry point: generate discovery_config.json from MCP config.
Reads MCP server definitions, connects to discover tools, generates
test parameters, and writes discovery_config.json.
Args:
mcp_config_path: Path to config file. If not provided, checks .mcp.json
first (Claude Code convention), then mcp_config.json
output_path: Path to write discovery_config.json (default: ./discovery_config.json)
skip_dangerous: Skip dangerous tools by default (default: True)
use_claude_api: Use Claude API to generate test parameters (default: True)
use_claude_code: Use Claude Code CLI instead of API (default: False)
use_copilot_cli: Use Copilot CLI instead of API (default: False)
"""
from pathlib import Path
from .config import McpConfig
from .mcp_client import McpClientManager
# Determine config path with fallback
if mcp_config_path:
mcp_config_path_str = mcp_config_path
else:
mcp_json = Path(".mcp.json")
mcp_config_json = Path("mcp_config.json")
if mcp_json.exists():
mcp_config_path_str = str(mcp_json)
elif mcp_config_json.exists():
mcp_config_path_str = str(mcp_config_json)
else:
logger.error("No config file found. Expected .mcp.json or mcp_config.json")
return
output_path_str = output_path or "./discovery_config.json"
logger.info(f"Loading MCP config from: {mcp_config_path_str}")
try:
with open(mcp_config_path_str) as f:
content = f.read()
mcp_config_dict = json.loads(content)
mcp_config = McpConfig.from_dict(mcp_config_dict)
except FileNotFoundError:
logger.error(f"Config file not found at {mcp_config_path_str}")
return
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in config file: {e}")
return
except Exception as e:
logger.error(f"Failed to parse MCP config: {e}")
return
# Initialize MCP client manager
manager = McpClientManager()
try:
await manager.initialize()
except Exception as e:
logger.error(f"Failed to initialize MCP client: {e}")
return
# Discover tools from all servers using list_all_tools
servers_tools: dict[str, list[dict[str, Any]]] = {}
try:
logger.debug("Discovering all tools from configured servers...")
all_tools = await manager.list_all_tools()
# Group tools by server name (extract from tool metadata)
for tool in all_tools:
# Tools don't inherently know their server, so we'll connect per server instead
pass
# Instead, iterate over enabled servers directly
enabled_servers = mcp_config.get_enabled_servers()
for server_name in enabled_servers.keys():
try:
logger.debug(f"Discovering tools from {server_name}...")
# The manager caches tools, so we can get them from cache after list_all_tools
# Using private method _get_server_tools since there's no public alternative
tools = await manager._get_server_tools(server_name)
tools_list: list[dict[str, Any]] = []
for tool in tools:
tools_list.append(
{
"name": tool.name,
"description": tool.description or "",
"inputSchema": tool.inputSchema or {},
}
)
servers_tools[server_name] = tools_list
logger.info(f"Found {len(tools_list)} tools in {server_name}")
except Exception as e:
logger.warning(f"Failed to discover tools from {server_name}: {e}")
continue
except Exception as e:
logger.error(f"Failed to list all tools: {e}")
# Cleanup
try:
await manager.cleanup()
except Exception as e:
logger.error(f"Cleanup failed: {e}")
# Build discovery config
logger.info("Generating discovery config...")
discovery_config = build_discovery_config(
servers_tools,
skip_dangerous=skip_dangerous,
use_claude_api=use_claude_api,
use_claude_code=use_claude_code,
use_copilot_cli=use_copilot_cli,
)
# Write config file (using synchronous write to avoid cleanup issues)
try:
config_content = json.dumps(discovery_config, indent=2)
with open(output_path_str, "w") as f:
f.write(config_content)
logger.info(f"Wrote discovery config to: {output_path_str}")
except Exception as e:
logger.error(f"Failed to write discovery config: {e}")
return
# Print summary
print_discovery_summary(discovery_config)
def main() -> None:
"""CLI entry point."""
import asyncio
parser = argparse.ArgumentParser(
description="Generate discovery config from MCP tool definitions"
)
parser.add_argument(
"--claude-api",
action="store_true",
default=True,
help="Use Claude API to generate test parameters (default: enabled)",
)
parser.add_argument(
"--no-claude-api",
action="store_false",
dest="claude_api",
help="Disable Claude API for test parameter generation",
)
parser.add_argument(
"--claude-code",
action="store_true",
help="Use Claude Code CLI instead of API (requires 'claude' command)",
)
parser.add_argument(
"--copilot-cli",
action="store_true",
help="Use Copilot CLI instead of API (requires 'copilot' command)",
)
parser.add_argument(
"--mcp-config",
default=None,
help="Path to MCP config file (default: .mcp.json or mcp_config.json)",
)
parser.add_argument(
"--output",
default="./discovery_config.json",
help="Path to write discovery_config.json (default: ./discovery_config.json)",
)
parser.add_argument(
"--include-dangerous",
action="store_true",
help="Include dangerous tools in config (default: skip them)",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(message)s",
)
asyncio.run(
generate_discovery_config_file(
mcp_config_path=args.mcp_config,
output_path=args.output,
skip_dangerous=not args.include_dangerous,
use_claude_api=args.claude_api,
use_claude_code=args.claude_code,
use_copilot_cli=args.copilot_cli,
)
)
if __name__ == "__main__":
main()
+372
View File
@@ -0,0 +1,372 @@
"""
Generate typed Python wrappers from MCP server tool definitions.
This module implements the progressive disclosure pattern by generating
Pydantic models and wrapper functions for each MCP tool.
"""
import asyncio
import logging
from pathlib import Path
from typing import Any
from .config import McpConfig
from .schema_utils import (
generate_pydantic_model,
sanitize_name,
)
logger = logging.getLogger("mcp_execution.generate_wrappers")
def find_project_root(start_dir: Path) -> Path:
"""Find project root by looking for .git directory.
Walks up from start_dir until finding .git or hitting filesystem root.
This ensures we find the right directory even if cwd is a subdirectory.
Args:
start_dir: Directory to start searching from
Returns:
Path to project root (directory containing .git), or start_dir if not found
"""
current = start_dir.resolve()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
return start_dir # Fallback to original if no .git found
def generate_tool_wrapper(server_name: str, tool_name: str, tool: Any) -> str:
"""
Generate Python wrapper function for a tool.
Args:
server_name: Name of the MCP server
tool_name: Name of the tool
tool: Tool definition from MCP
Returns:
Python code for wrapper function
Example output:
```python
async def git_status(params: GitStatusParams) -> Dict[str, Any]:
'''Get git repository status'''
from runtime.mcp_client import call_mcp_tool
from runtime.normalize_fields import normalize_field_names
result = await call_mcp_tool("git__git_status", params.model_dump())
normalized = normalize_field_names(result, "git")
return GitStatusResult.model_validate(normalized)
```
"""
safe_tool_name = sanitize_name(tool_name)
tool_identifier = f"{server_name}__{tool_name}"
# Get tool description
description = getattr(tool, "description", "MCP tool wrapper")
description_escaped = description.replace('"""', '\\"\\"\\"')
# Generate parameter model name
params_model = f"{safe_tool_name.title().replace('_', '')}Params"
# Generate wrapper function
wrapper = f'''
async def {safe_tool_name}(params: {params_model}) -> Dict[str, Any]:
"""
{description_escaped}
Args:
params: Tool parameters
Returns:
Tool execution result
"""
from runtime.mcp_client import call_mcp_tool
from runtime.normalize_fields import normalize_field_names
# Call tool
result = await call_mcp_tool("{tool_identifier}", params.model_dump(exclude_none=True))
# Defensive unwrapping
unwrapped = getattr(result, "value", result)
# Apply field normalization
normalized = normalize_field_names(unwrapped, "{server_name}")
return normalized
'''
return wrapper
def generate_params_model(tool_name: str, tool: Any) -> str:
"""
Generate Pydantic model for tool parameters.
Args:
tool_name: Name of the tool
tool: Tool definition from MCP
Returns:
Python code for Pydantic params model
"""
safe_tool_name = sanitize_name(tool_name)
model_name = f"{safe_tool_name.title().replace('_', '')}Params"
# Get input schema
input_schema = getattr(tool, "inputSchema", {})
if not input_schema or input_schema.get("type") != "object":
# No parameters
return f'''
class {model_name}(BaseModel):
"""Parameters for {tool_name}."""
pass
'''
description = f"Parameters for {tool_name}"
return generate_pydantic_model(model_name, input_schema, description)
def generate_server_module(server_name: str, tools: list[Any], output_dir: Path) -> None:
"""
Generate complete module for a server's tools.
Creates:
- Individual tool files (servers/{server_name}/{tool_name}.py)
- Barrel export (__init__.py)
- README.md
Args:
server_name: Name of the MCP server
tools: List of tool definitions
output_dir: Output directory (servers/)
"""
server_dir = output_dir / server_name
server_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Generating wrappers for server: {server_name} ({len(tools)} tools)")
imports = [
"from typing import Any, Dict, List, Optional",
"from pydantic import BaseModel, Field",
"from typing import Literal",
]
tool_names = []
for tool in tools:
tool_name = sanitize_name(tool.name)
tool_names.append(tool_name)
# Generate tool file
tool_file = server_dir / f"{tool_name}.py"
# Generate models and wrapper
params_model = generate_params_model(tool.name, tool)
wrapper_func = generate_tool_wrapper(server_name, tool.name, tool)
# Write tool file
tool_code = "\n".join(imports) + "\n\n" + params_model + "\n" + wrapper_func
tool_file.write_text(tool_code)
logger.debug(f"Generated: {tool_file}")
# Generate __init__.py (barrel export)
init_file = server_dir / "__init__.py"
init_imports = [f"from .{name} import {name}" for name in tool_names]
init_all = f"__all__ = {tool_names}"
init_content = "\n".join(init_imports) + "\n\n" + init_all
init_file.write_text(init_content)
# Generate README.md
readme_file = server_dir / "README.md"
readme_content = f"""# {server_name} MCP Tools
Auto-generated wrappers for {server_name} MCP server.
## Tools
{
chr(10).join(
[f"- `{tool.name}`: {getattr(tool, 'description', 'No description')}" for tool in tools]
)
}
## Usage
```python
from servers.{server_name} import {tool_names[0] if tool_names else "tool_name"}
# Use the tool
result = await {tool_names[0] if tool_names else "tool_name"}(params)
```
**Note**: This file is auto-generated. Do not edit manually.
"""
readme_file.write_text(readme_content)
async def generate_wrappers(config_path: Path | None = None) -> None:
"""
Main wrapper generation orchestrator.
1. Load config from global + project (merged, project overrides)
2. For each server:
a. Connect and list tools
b. Generate wrappers
c. Write to servers/{server}/
3. Generate top-level __init__.py
Args:
config_path: Path to config file. If provided, uses only that file.
Otherwise merges global (~/.claude/mcp_config.json) with
project config (.mcp.json or mcp_config.json)
"""
logger.info("Starting wrapper generation...")
import aiofiles
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Load config with merging support
if config_path:
if not config_path.exists():
logger.error(f"Config file not found: {config_path}")
return
logger.info(f"Using explicit config: {config_path}")
async with aiofiles.open(config_path) as f:
content = await f.read()
config = McpConfig.model_validate_json(content)
else:
# Config merging: global + project (project overrides)
project_root = find_project_root(Path.cwd())
mcp_json = project_root / ".mcp.json"
mcp_config_json = project_root / "mcp_config.json"
global_config = Path.home() / ".claude" / "mcp_config.json"
global_cfg: McpConfig | None = None
project_cfg: McpConfig | None = None
# Load global config if exists
if global_config.exists():
try:
async with aiofiles.open(global_config) as f:
content = await f.read()
global_cfg = McpConfig.model_validate_json(content)
logger.info(
f"Loaded global config: {global_config} ({len(global_cfg.mcpServers)} servers)"
)
except Exception as e:
logger.warning(f"Failed to load global config {global_config}: {e}")
# Load project config if exists
project_config_file = None
if mcp_json.exists():
project_config_file = mcp_json
elif mcp_config_json.exists():
project_config_file = mcp_config_json
if project_config_file:
try:
async with aiofiles.open(project_config_file) as f:
content = await f.read()
project_cfg = McpConfig.model_validate_json(content)
logger.info(
f"Loaded project config: {project_config_file} ({len(project_cfg.mcpServers)} servers)"
)
except Exception as e:
logger.error(f"Failed to load project config {project_config_file}: {e}")
return
# Merge configs (project overrides global)
if global_cfg and project_cfg:
config = global_cfg.merge(project_cfg)
logger.info(f"Merged configs: {len(config.mcpServers)} servers total")
elif project_cfg:
config = project_cfg
elif global_cfg:
config = global_cfg
else:
logger.error(
"No config file found. Expected .mcp.json or mcp_config.json, or global ~/.claude/mcp_config.json"
)
return
# Output directory
output_dir = Path(__file__).parent.parent.parent / "servers"
output_dir.mkdir(exist_ok=True)
# Generate for each server
for server_name, server_config in config.mcpServers.items():
try:
if server_config.disabled:
logger.info(f"Skipping disabled server: {server_name}")
continue
logger.info(f"Connecting to server: {server_name} (transport: {server_config.type})")
# Create appropriate client based on transport type
if server_config.type == "stdio":
from mcp.client.stdio import stdio_client
server_params = StdioServerParameters(
command=server_config.command,
args=server_config.args,
env=server_config.env,
)
client_ctx = stdio_client(server_params)
elif server_config.type == "sse":
from mcp.client.sse import sse_client
client_ctx = sse_client(url=server_config.url, headers=server_config.headers or {})
elif server_config.type == "http":
from mcp.client.streamable_http import streamablehttp_client
client_ctx = streamablehttp_client(
url=server_config.url, headers=server_config.headers or {}
)
else:
logger.warning(
f"Skipping {server_name}: unsupported transport type '{server_config.type}'"
)
continue
# Connect and list tools using proper context manager pattern
async with client_ctx as streams:
# Handle different return signatures
if server_config.type == "http":
read, write, _get_session_id = streams
else:
read, write = streams
async with ClientSession(read, write) as session:
await session.initialize()
# List tools
tools_response = await session.list_tools()
tools = tools_response.tools
logger.info(f"Found {len(tools)} tools for {server_name}")
# Generate wrappers
generate_server_module(server_name, tools, output_dir)
except Exception as e:
logger.error(f"Failed to generate wrappers for {server_name}: {e}")
# Continue with other servers
continue
logger.info("Wrapper generation complete!")
def main() -> None:
"""CLI entry point."""
asyncio.run(generate_wrappers())
if __name__ == "__main__":
main()
+251
View File
@@ -0,0 +1,251 @@
"""
Script execution harness for MCP-enabled Python scripts.
This harness:
1. Initializes MCP client manager
2. Executes user script with MCP tools available
3. Handles signals gracefully (SIGINT/SIGTERM)
4. Cleans up all connections on exit
"""
import asyncio
import logging
import runpy
import signal
import sys
from pathlib import Path
from typing import Any, NoReturn
from .env_utils import load_project_env
from .exceptions import McpExecutionError
from .mcp_client import get_mcp_client_manager
# Configure logging to stderr
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s", stream=sys.stderr)
logger = logging.getLogger("mcp_execution.harness")
def _parse_arguments() -> Path:
"""
Parse command-line arguments.
Returns:
Path to script
"""
if len(sys.argv) < 2:
logger.error("Usage: python -m runtime.harness <script_path>")
sys.exit(1)
return Path(sys.argv[1]).resolve()
class _AsyncgenErrorFilter(logging.Filter):
"""Filter that suppresses asyncgen cleanup errors from MCP SDK."""
def filter(self, record: logging.LogRecord) -> bool:
# Suppress the "error occurred during closing of asynchronous generator" message
if "asynchronous generator" in record.getMessage().lower():
return False
return True
def _suppress_asyncgen_errors():
"""
Suppress asyncgen cleanup error logs from asyncio.
The MCP SDK's stdio_client uses async generators that can raise RuntimeErrors
about cancel scopes when closed in a different task context. These errors are
harmless cleanup artifacts that we suppress to avoid alarming users.
This function patches asyncio.run() to install a silent exception handler on
any event loops it creates, ensuring scripts using asyncio.run() don't see
these harmless errors.
"""
# Add filter to asyncio logger to suppress asyncgen cleanup errors
asyncio_logger = logging.getLogger("asyncio")
asyncio_logger.addFilter(_AsyncgenErrorFilter())
# Define silent exception handler
def silent_exception_handler(loop, context):
# Suppress asyncgen and cancel scope related errors
exception = context.get("exception")
message = context.get("message", "")
if exception:
err_str = str(exception).lower()
if "cancel scope" in err_str or "asyncgen" in err_str:
return
if "asyncgen" in message.lower() or "asynchronous generator" in message.lower():
return
# For other exceptions, use default handler
loop.default_exception_handler(context)
# Store handler for later use
_suppress_asyncgen_errors._handler = silent_exception_handler
# Monkey-patch asyncio.run to install our exception handler
def patched_run(main, *, debug=None, **kwargs):
# Create loop manually so we can set exception handler
loop = asyncio.new_event_loop()
loop.set_exception_handler(silent_exception_handler)
try:
asyncio.set_event_loop(loop)
if debug is not None:
loop.set_debug(debug)
return loop.run_until_complete(main)
finally:
try:
# Suppress errors during shutdown
_cancel_all_tasks(loop)
loop.run_until_complete(loop.shutdown_asyncgens())
loop.run_until_complete(loop.shutdown_default_executor())
except Exception:
pass
finally:
asyncio.set_event_loop(None)
loop.close()
asyncio.run = patched_run
def _cancel_all_tasks(loop):
"""Cancel all pending tasks in the loop."""
to_cancel = asyncio.all_tasks(loop)
if not to_cancel:
return
for task in to_cancel:
task.cancel()
loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True))
for task in to_cancel:
if task.cancelled():
continue
if task.exception() is not None:
pass # Suppress task exceptions during cleanup
def _execute_direct(script_path: Path) -> int:
"""
Execute script in direct mode (current process, no sandbox).
Args:
script_path: Path to Python script
Returns:
Exit code
"""
logger.info("=== Direct Mode ===")
# Add project root and src/ to Python path for imports
src_path = Path(__file__).parent.parent
if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path))
logger.debug(f"Added to sys.path: {src_path}")
project_root = src_path.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
logger.debug(f"Added to sys.path: {project_root}")
# Suppress asyncgen cleanup errors from MCP SDK
# This must be done BEFORE any event loop is created
_suppress_asyncgen_errors()
# Create persistent event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Set exception handler to suppress asyncgen errors
if hasattr(_suppress_asyncgen_errors, "_handler"):
loop.set_exception_handler(_suppress_asyncgen_errors._handler)
# Initialize MCP client manager
manager = get_mcp_client_manager()
try:
loop.run_until_complete(manager.initialize())
logger.info("MCP client manager initialized")
except McpExecutionError as e:
logger.error(f"Failed to initialize MCP client: {e}")
return 1
# Set up signal handling
def signal_handler(signum: int, frame: Any) -> None:
"""Handle shutdown signals."""
signal_name = signal.Signals(signum).name
logger.info(f"Received {signal_name}, shutting down...")
sys.exit(130)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Execute script
exit_code = 0
try:
logger.info(f"Executing script: {script_path}")
runpy.run_path(str(script_path), run_name="__main__")
logger.info("Script execution completed")
except KeyboardInterrupt:
logger.info("Execution interrupted by user")
exit_code = 130
except Exception as e:
logger.error(f"Script execution failed: {e}", exc_info=True)
exit_code = 1
finally:
# Cleanup
logger.debug("Cleaning up MCP connections...")
try:
loop.run_until_complete(manager.cleanup())
logger.info("Cleanup complete")
except BaseException as e:
# Suppress BaseExceptionGroup from async generators
if type(e).__name__ == "BaseExceptionGroup":
logger.debug("Suppressed BaseExceptionGroup during cleanup")
else:
logger.error(f"Cleanup failed: {e}", exc_info=True)
if exit_code == 0:
exit_code = 1
finally:
# Reset asyncgen hooks before closing loop
sys.set_asyncgen_hooks(firstiter=None, finalizer=None)
loop.close()
return exit_code
def main() -> NoReturn:
"""Entry point for the harness."""
# 0. Load .env file (if present) for API keys
if load_project_env():
logger.info("Loaded .env file")
# 1. Parse CLI arguments
script_path = _parse_arguments()
# 2. Validate script exists
if not script_path.exists():
logger.error(f"Script not found: {script_path}")
sys.exit(1)
if not script_path.is_file():
logger.error(f"Not a file: {script_path}")
sys.exit(1)
logger.info(f"Script: {script_path}")
# 3. Execute script
exit_code = _execute_direct(script_path)
sys.exit(exit_code)
if __name__ == "__main__":
main()
+843
View File
@@ -0,0 +1,843 @@
"""MCP Client Manager with state machine architecture for lazy loading and connection.
This module provides the core runtime client manager that connects to MCP servers
on-demand, caches tools, and manages the lifecycle of server connections using
an explicit state machine pattern for clarity and debugging.
Uses dispatch tables for result unwrapping to reduce cyclomatic complexity.
"""
import asyncio
import json
import logging
import os
import re
import sys
from enum import Enum
from functools import lru_cache
from pathlib import Path
from typing import Any
import aiofiles
from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import Tool
from .config import McpConfig, ServerConfig
from .exceptions import (
ConfigurationError,
ServerConnectionError,
ToolExecutionError,
ToolNotFoundError,
)
logger = logging.getLogger("mcp_execution.client")
# ===========================================================================
# Project Root Detection - Handles cwd being a subdirectory
# ===========================================================================
def find_project_root(start_dir: Path) -> Path:
"""Find project root by looking for .git directory.
Walks up from start_dir until finding .git or hitting filesystem root.
This ensures we find the right directory even if cwd is a subdirectory.
Args:
start_dir: Directory to start searching from
Returns:
Path to project root (directory containing .git), or start_dir if not found
"""
current = start_dir.resolve()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
return start_dir # Fallback to original if no .git found
# ===========================================================================
# Result Unwrapping Dispatch - Reduces complexity in call_tool
# ===========================================================================
def _unwrap_result(result: Any) -> Any:
"""Unwrap MCP call result using strategy dispatch.
Tries multiple unwrapping strategies in order:
1. result.value (most common MCP response format)
2. result.content (alternative response format)
3. result itself (raw fallback)
Args:
result: Raw MCP tool result
Returns:
Unwrapped result value
"""
# Strategy 1: Try result.value (most common)
if hasattr(result, "value"):
return result.value
# Strategy 2: Try result.content (alternative format)
if hasattr(result, "content"):
return result.content
# Strategy 3: Fall back to result itself
return result
def _unwrap_text_content(content_list: list[Any]) -> Any:
"""Unwrap text content from MCP response list.
Handles the common pattern where MCP returns a list with text items.
Attempts to parse JSON if the text looks like JSON.
Args:
content_list: List of content items from MCP response
Returns:
Extracted and possibly parsed content
"""
if not content_list:
return content_list
first_item = content_list[0]
if not hasattr(first_item, "text"):
return content_list
text_content = first_item.text
# Try to parse as JSON if it looks like JSON
if isinstance(text_content, str) and text_content.strip().startswith(("{", "[")):
try:
return json.loads(text_content)
except json.JSONDecodeError:
pass
return text_content
def _unwrap_mcp_response(result: Any) -> Any:
"""Full unwrapping pipeline for MCP responses.
Combines _unwrap_result and _unwrap_text_content for complete
response handling.
Args:
result: Raw MCP tool result
Returns:
Fully unwrapped result value
"""
unwrapped = _unwrap_result(result)
# Handle list responses with text content
if isinstance(unwrapped, list) and len(unwrapped) > 0:
return _unwrap_text_content(unwrapped)
return unwrapped
# Dispatch table for result unwrapping strategies
RESULT_UNWRAP_STRATEGIES = [
("value", lambda r: r.value),
("content", lambda r: r.content),
]
# ===========================================================================
# Config Loading Helpers - Reduces complexity in initialize
# ===========================================================================
async def _load_config_from_path(config_path: str) -> McpConfig:
"""Load MCP config from explicit path.
Args:
config_path: Path to config file
Returns:
Loaded McpConfig
Raises:
ConfigurationError: If load fails
"""
from pathlib import Path
path = Path(config_path)
if not path.exists():
raise ConfigurationError(f"Config file not found: {config_path}")
try:
async with aiofiles.open(path) as f:
content = await f.read()
return McpConfig.model_validate_json(content)
except json.JSONDecodeError as e:
raise ConfigurationError(f"Invalid JSON in config file {config_path}: {e}")
except Exception as e:
raise ConfigurationError(f"Failed to load config from {config_path}: {e}")
def _find_project_config() -> Path | None:
"""Find project config file (.mcp.json or mcp_config.json).
Returns:
Path to project config if found, None otherwise
"""
project_root = find_project_root(Path.cwd())
mcp_json = project_root / ".mcp.json"
mcp_config_json = project_root / "mcp_config.json"
if mcp_json.exists():
return mcp_json
elif mcp_config_json.exists():
return mcp_config_json
return None
def _merge_configs(global_cfg: McpConfig | None, project_cfg: McpConfig | None) -> McpConfig | None:
"""Merge global and project configs.
Project config takes precedence for servers with same name.
Args:
global_cfg: Global config (may be None)
project_cfg: Project config (may be None)
Returns:
Merged config or None if both are None
"""
if global_cfg and project_cfg:
return global_cfg.merge(project_cfg)
return project_cfg or global_cfg
class ConnectionState(Enum):
"""Explicit states for the MCP Client Manager lifecycle.
States:
UNINITIALIZED: Manager created but not initialized
INITIALIZED: Configuration loaded, no server connections
CONNECTED: At least one server connection established
"""
UNINITIALIZED = "uninitialized"
INITIALIZED = "initialized"
CONNECTED = "connected"
class McpClientManager:
"""Lazy-loading MCP client manager with explicit state machine.
This manager implements a state machine pattern for managing MCP server
connections with the following characteristics:
- Lazy initialization: Config loaded on initialize(), servers NOT connected
- Lazy connection: Servers connect on first call_tool() call
- Tool caching: Cache tools per server to avoid repeated list_tools calls
- Defensive unwrapping: Handle response.value and fallback patterns
- Explicit state tracking: Clear state transitions with validation
State Transitions:
UNINITIALIZED -> INITIALIZED (via initialize())
INITIALIZED -> CONNECTED (via _connect_to_server())
any state -> UNINITIALIZED (via cleanup())
Attributes:
_state: Current connection state
_clients: Mapping of server names to active client sessions
_tool_cache: Cached tools per server to avoid repeated queries
_config: Loaded MCP configuration
_stdio_contexts: Stdio context managers for proper lifecycle management
_session_contexts: Session context managers for proper lifecycle management
_read_streams: Active stdio read streams
_write_streams: Active stdio write streams
"""
def __init__(self) -> None:
"""Initialize an uninitialized MCP Client Manager."""
self._state: ConnectionState = ConnectionState.UNINITIALIZED
self._clients: dict[str, ClientSession] = {}
self._tool_cache: dict[str, list[Tool]] = {}
self._config: McpConfig | None = None
self._stdio_contexts: dict[str, Any] = {} # Store stdio context managers
self._session_contexts: dict[str, Any] = {} # Store session context managers
self._read_streams: dict[str, Any] = {}
self._write_streams: dict[str, Any] = {}
def _validate_state(self, required_state: ConnectionState, operation: str) -> None:
"""Validate that the manager is in the required state for an operation.
Args:
required_state: The state required to perform the operation
operation: Name of the operation being attempted (for error messages)
Raises:
ConfigurationError: If the manager is not in the required state
"""
if self._state.value != required_state.value:
raise ConfigurationError(
f"Cannot {operation}: Manager is in state '{self._state.value}', "
f"but requires state '{required_state.value}'"
)
def _validate_state_at_least(self, minimum_state: ConnectionState, operation: str) -> None:
"""Validate that the manager has at least reached the minimum state.
Args:
minimum_state: The minimum state required
operation: Name of the operation being attempted
Raises:
ConfigurationError: If the manager has not reached the minimum state
"""
state_order = [
ConnectionState.UNINITIALIZED,
ConnectionState.INITIALIZED,
ConnectionState.CONNECTED,
]
current_idx = state_order.index(self._state)
required_idx = state_order.index(minimum_state)
if current_idx < required_idx:
raise ConfigurationError(
f"Cannot {operation}: Manager is in state '{self._state.value}', "
f"but requires at least state '{minimum_state.value}'"
)
def _mark_initialized(self) -> None:
"""Transition to INITIALIZED state."""
self._state = ConnectionState.INITIALIZED
logger.debug("State transition: UNINITIALIZED -> INITIALIZED")
def _mark_connected(self) -> None:
"""Transition to CONNECTED state."""
if self._state == ConnectionState.INITIALIZED:
self._state = ConnectionState.CONNECTED
logger.debug("State transition: INITIALIZED -> CONNECTED")
def _mark_uninitialized(self) -> None:
"""Transition back to UNINITIALIZED state."""
self._state = ConnectionState.UNINITIALIZED
logger.debug("State transition: -> UNINITIALIZED")
async def initialize(self, config_path: Path | None = None) -> None:
"""Initialize the manager by loading configuration.
This method loads the MCP configuration from JSON files but does NOT
establish any server connections. Connections are established lazily
on the first tool call.
Config merging: If both global (~/.claude/mcp_config.json) and project
configs exist, they are merged with project config taking precedence
for servers with the same name.
Args:
config_path: Optional path to config file. If not provided,
merges global config with project config (.mcp.json or mcp_config.json)
Raises:
ConfigurationError: If no config file is found or config is invalid
"""
self._validate_state(ConnectionState.UNINITIALIZED, "initialize")
# If explicit path provided, use only that
if config_path:
if not config_path.exists():
raise ConfigurationError(f"Config file not found: {config_path}")
try:
async with aiofiles.open(config_path) as f:
content = await f.read()
self._config = McpConfig.model_validate_json(content)
except json.JSONDecodeError as e:
raise ConfigurationError(f"Invalid JSON in config file {config_path}: {e}")
except Exception as e:
raise ConfigurationError(f"Failed to load config from {config_path}: {e}")
else:
# Config merging: global + project (project overrides)
project_root = find_project_root(Path.cwd())
mcp_json = project_root / ".mcp.json"
mcp_config_json = project_root / "mcp_config.json"
global_config = Path.home() / ".claude" / "mcp_config.json"
global_cfg: McpConfig | None = None
project_cfg: McpConfig | None = None
# Load global config if exists
if global_config.exists():
try:
async with aiofiles.open(global_config) as f:
content = await f.read()
global_cfg = McpConfig.model_validate_json(content)
logger.info(
f"Loaded global config: {global_config} ({len(global_cfg.mcpServers)} servers)"
)
except Exception as e:
logger.warning(f"Failed to load global config {global_config}: {e}")
# Load project config if exists (prefer .mcp.json over mcp_config.json)
project_config_file = None
if mcp_json.exists():
project_config_file = mcp_json
elif mcp_config_json.exists():
project_config_file = mcp_config_json
if project_config_file:
try:
async with aiofiles.open(project_config_file) as f:
content = await f.read()
project_cfg = McpConfig.model_validate_json(content)
logger.info(
f"Loaded project config: {project_config_file} ({len(project_cfg.mcpServers)} servers)"
)
except json.JSONDecodeError as e:
raise ConfigurationError(
f"Invalid JSON in config file {project_config_file}: {e}"
)
except Exception as e:
raise ConfigurationError(
f"Failed to load config from {project_config_file}: {e}"
)
# Merge configs (project overrides global)
if global_cfg and project_cfg:
self._config = global_cfg.merge(project_cfg)
logger.info(f"Merged configs: {len(self._config.mcpServers)} servers total")
elif project_cfg:
self._config = project_cfg
elif global_cfg:
self._config = global_cfg
else:
raise ConfigurationError(
f"No config file found. Expected .mcp.json or mcp_config.json in {project_root}, "
f"or global config at {global_config}"
)
enabled_count = len(self._config.get_enabled_servers())
logger.info(
f"Configuration loaded: {len(self._config.mcpServers)} servers total, "
f"{enabled_count} enabled"
)
self._mark_initialized()
async def _connect_to_server(self, server_name: str, config: ServerConfig) -> None:
"""Establish connection to a single MCP server on-demand.
This method is called lazily when a tool from the server is first invoked.
Supports stdio, SSE, and HTTP transports.
Args:
server_name: Name of the server to connect to
config: Server configuration containing connection details
Raises:
ServerConnectionError: If connection fails
"""
if server_name in self._clients:
logger.debug(f"Server '{server_name}' already connected")
return
logger.info(f"Connecting to MCP server: {server_name} (transport: {config.type})")
try:
# Create appropriate client based on transport type
if config.type == "stdio":
await self._connect_stdio(server_name, config)
elif config.type == "sse":
await self._connect_sse(server_name, config)
elif config.type == "http":
await self._connect_http(server_name, config)
else:
raise ServerConnectionError(f"Unsupported transport type: {config.type}")
self._mark_connected()
logger.info(f"Successfully connected to server: {server_name}")
except Exception as e:
logger.error(f"Failed to connect to server '{server_name}': {e}")
# Clean up any partially created contexts
if server_name in self._stdio_contexts:
try:
await self._stdio_contexts[server_name].__aexit__(None, None, None)
except Exception:
pass
del self._stdio_contexts[server_name]
raise ServerConnectionError(f"Could not connect to MCP server '{server_name}': {e}")
def _substitute_env_vars(self, env: dict[str, str] | None) -> dict[str, str] | None:
"""Substitute ${VAR} placeholders with actual environment variable values."""
if not env:
return env
result = {}
pattern = re.compile(r"\$\{([^}]+)\}")
for key, value in env.items():
# Replace ${VAR} with os.environ.get('VAR', '')
def replacer(match: re.Match) -> str:
var_name = match.group(1)
return os.environ.get(var_name, "")
result[key] = pattern.sub(replacer, value)
return result
async def _connect_stdio(self, server_name: str, config: ServerConfig) -> None:
"""Connect to stdio MCP server."""
# Substitute environment variable placeholders in config.env
resolved_env = self._substitute_env_vars(config.env)
# Merge os.environ with config-specific env vars
# This ensures API keys loaded from .env are available to subprocess
# MCP SDK's get_default_environment() only includes basic vars (PATH, HOME, etc.)
full_env = {**os.environ, **(resolved_env or {})}
# Create stdio server parameters
server_params = StdioServerParameters(
command=config.command,
args=config.args,
env=full_env,
)
# Establish stdio connection and store the context manager
stdio_ctx = stdio_client(server_params)
streams = await stdio_ctx.__aenter__()
read_stream, write_stream = streams
# Store the context manager for cleanup
self._stdio_contexts[server_name] = stdio_ctx
self._read_streams[server_name] = read_stream
self._write_streams[server_name] = write_stream
# Create and initialize session
session = ClientSession(read_stream, write_stream)
client = await session.__aenter__()
await client.initialize()
# Store client and session context
self._clients[server_name] = client
self._session_contexts[server_name] = session
async def _connect_sse(self, server_name: str, config: ServerConfig) -> None:
"""Connect to SSE MCP server."""
# Establish SSE connection
sse_ctx = sse_client(url=config.url, headers=config.headers or {})
streams = await sse_ctx.__aenter__()
read_stream, write_stream = streams
# Store the context manager for cleanup
self._stdio_contexts[server_name] = sse_ctx
self._read_streams[server_name] = read_stream
self._write_streams[server_name] = write_stream
# Create and initialize session
session = ClientSession(read_stream, write_stream)
client = await session.__aenter__()
await client.initialize()
# Store client and session context
self._clients[server_name] = client
self._session_contexts[server_name] = session
async def _connect_http(self, server_name: str, config: ServerConfig) -> None:
"""Connect to Streamable HTTP MCP server."""
# Establish HTTP connection
http_ctx = streamablehttp_client(url=config.url, headers=config.headers or {})
result = await http_ctx.__aenter__()
# streamablehttp_client returns (read, write, get_session_id)
read_stream, write_stream, _get_session_id = result
# Store the context manager for cleanup
self._stdio_contexts[server_name] = http_ctx
self._read_streams[server_name] = read_stream
self._write_streams[server_name] = write_stream
# Create and initialize session
session = ClientSession(read_stream, write_stream)
client = await session.__aenter__()
await client.initialize()
# Store client and session context
self._clients[server_name] = client
self._session_contexts[server_name] = session
async def _get_server_tools(self, server_name: str) -> list[Tool]:
"""Get list of tools from a server, using cache if available.
Args:
server_name: Name of the server to query
Returns:
List of tool objects from the server
Raises:
ServerConnectionError: If not connected to the server
"""
# Check cache first
if server_name in self._tool_cache:
logger.debug(f"Using cached tools for server: {server_name}")
return self._tool_cache[server_name]
# Ensure we're connected
if server_name not in self._clients:
raise ServerConnectionError(f"Not connected to server: {server_name}")
# Query server for tools
try:
client = self._clients[server_name]
result = await client.list_tools()
# Defensive unwrapping: handle response.tools
tools: list[Tool] = result.tools if hasattr(result, "tools") else []
# Cache the results
self._tool_cache[server_name] = tools
logger.debug(f"Cached {len(tools)} tools for server: {server_name}")
return tools
except Exception as e:
logger.error(f"Failed to list tools from server '{server_name}': {e}")
raise ServerConnectionError(f"Could not list tools from server '{server_name}': {e}")
async def call_tool(
self, tool_identifier: str, params: dict[str, Any], max_retries: int = 1
) -> Any:
"""Call an MCP tool with lazy server connection and automatic retry.
This is the core method that implements lazy loading. Servers are only
connected when their tools are first invoked. On failure, automatically
retries up to max_retries times before raising an error.
Tool Identifier Format: "serverName__toolName"
Args:
tool_identifier: Tool identifier in format "serverName__toolName"
params: Dictionary of parameters to pass to the tool
max_retries: Maximum number of retry attempts on failure (default: 1)
Returns:
The tool execution result (unwrapped from response)
Raises:
ConfigurationError: If manager not initialized
ToolNotFoundError: If tool doesn't exist on the specified server
ToolExecutionError: If tool execution fails after all retries
ServerConnectionError: If unable to connect to server
"""
self._validate_state_at_least(ConnectionState.INITIALIZED, "call tool")
if not self._config:
raise ConfigurationError("Configuration not loaded")
# Parse tool identifier
if "__" not in tool_identifier:
raise ToolNotFoundError(
f"Invalid tool identifier '{tool_identifier}'. "
f"Expected format: 'serverName__toolName'"
)
server_name, tool_name = tool_identifier.split("__", 1)
# Get server configuration
server_config = self._config.get_server(server_name)
if not server_config:
raise ToolNotFoundError(
f"Server '{server_name}' not found in configuration. "
f"Available servers: {list(self._config.mcpServers.keys())}"
)
if server_config.disabled:
raise ToolNotFoundError(f"Server '{server_name}' is disabled in configuration")
# Lazy connection: connect to server if not already connected
if server_name not in self._clients:
logger.debug(f"Lazy connecting to server '{server_name}' for tool '{tool_name}'")
await self._connect_to_server(server_name, server_config)
# Verify tool exists on server
tools = await self._get_server_tools(server_name)
tool_names = [tool.name for tool in tools]
if tool_name not in tool_names:
raise ToolNotFoundError(
f"Tool '{tool_name}' not found on server '{server_name}'. "
f"Available tools: {tool_names}"
)
# Execute the tool with retry logic
last_error: Exception | None = None
for attempt in range(max_retries + 1):
try:
client = self._clients[server_name]
logger.info(
f"Executing tool: {tool_identifier}"
+ (f" (attempt {attempt + 1})" if attempt > 0 else "")
)
logger.debug(f"Tool parameters: {params}")
result = await client.call_tool(tool_name, params)
# Use dispatch-based unwrapping for reduced complexity
unwrapped = _unwrap_mcp_response(result)
logger.debug(f"Tool execution result: {unwrapped}")
return unwrapped
except Exception as e:
last_error = e
if attempt < max_retries:
print(
f"⚠️ MCP call failed (attempt {attempt + 1}/{max_retries + 1}), retrying in 1s...",
file=sys.stderr,
)
logger.warning(
f"Tool execution attempt {attempt + 1} failed for '{tool_identifier}': {e}"
)
await asyncio.sleep(1) # Brief delay before retry
else:
logger.error(
f"Tool execution failed after {max_retries + 1} attempts for '{tool_identifier}': {e}"
)
# All retries exhausted
print(f"❌ MCP call failed after {max_retries + 1} attempts: {last_error}", file=sys.stderr)
raise ToolExecutionError(
f"Failed to execute tool '{tool_identifier}' after {max_retries + 1} attempts: {last_error}"
)
async def list_all_tools(self) -> list[Tool]:
"""List all available tools from all enabled servers.
This method connects to all enabled servers to retrieve their tool lists.
Results are cached per server to avoid repeated queries.
Returns:
List of all available tools across all enabled servers
Raises:
ConfigurationError: If manager not initialized
ServerConnectionError: If unable to connect to any server
"""
self._validate_state_at_least(ConnectionState.INITIALIZED, "list all tools")
if not self._config:
raise ConfigurationError("Configuration not loaded")
all_tools: list[Tool] = []
enabled_servers = self._config.get_enabled_servers()
if not enabled_servers:
logger.warning("No enabled servers configured")
return all_tools
logger.info(f"Listing tools from {len(enabled_servers)} enabled servers")
for server_name, server_config in enabled_servers.items():
try:
# Connect to server if not already connected
if server_name not in self._clients:
await self._connect_to_server(server_name, server_config)
# Get tools from server (uses cache if available)
tools = await self._get_server_tools(server_name)
all_tools.extend(tools)
logger.debug(f"Server '{server_name}': {len(tools)} tools")
except Exception as e:
logger.error(f"Failed to list tools from server '{server_name}': {e}")
# Continue with other servers rather than failing completely
logger.info(f"Total tools available: {len(all_tools)}")
return all_tools
async def cleanup(self) -> None:
"""Close all connections and reset manager to uninitialized state.
This method gracefully closes all active server connections and clears
all cached data, returning the manager to UNINITIALIZED state.
"""
logger.info("Cleaning up MCP Client Manager")
# Properly exit all session contexts
for server_name in list(self._session_contexts.keys()):
try:
session_ctx = self._session_contexts[server_name]
await session_ctx.__aexit__(None, None, None)
logger.debug(f"Closed session context for server: {server_name}")
except (RuntimeError, asyncio.CancelledError) as e:
# Ignore cancel scope errors that can occur when contexts are entered
# and exited in different event loop tasks (e.g., when scripts call asyncio.run())
if "cancel scope" in str(e).lower() or isinstance(e, asyncio.CancelledError):
logger.debug(
f"Ignoring cancel scope error during cleanup for '{server_name}': {e}"
)
else:
logger.error(f"Error closing session context for '{server_name}': {e}")
except Exception as e:
logger.error(f"Error closing session context for '{server_name}': {e}")
# Properly exit all stdio contexts
for server_name in list(self._stdio_contexts.keys()):
try:
stdio_ctx = self._stdio_contexts[server_name]
await stdio_ctx.__aexit__(None, None, None)
logger.debug(f"Closed stdio context for server: {server_name}")
except (RuntimeError, asyncio.CancelledError) as e:
# Ignore cancel scope errors that can occur when contexts are entered
# and exited in different event loop tasks (e.g., when scripts call asyncio.run())
if "cancel scope" in str(e).lower() or isinstance(e, asyncio.CancelledError):
logger.debug(
f"Ignoring cancel scope error during cleanup for '{server_name}': {e}"
)
else:
logger.error(f"Error closing stdio context for '{server_name}': {e}")
except Exception as e:
logger.error(f"Error closing stdio context for '{server_name}': {e}")
# Clear all state
self._clients.clear()
self._session_contexts.clear()
self._stdio_contexts.clear()
self._tool_cache.clear()
self._read_streams.clear()
self._write_streams.clear()
self._config = None
self._mark_uninitialized()
logger.info("Cleanup complete")
# Singleton pattern using lru_cache (thread-safe)
@lru_cache(maxsize=1)
def get_mcp_client_manager() -> McpClientManager:
"""Get or create the singleton MCP Client Manager instance.
This function uses functools.lru_cache to ensure only one instance
of the manager exists, providing thread-safe singleton behavior.
Returns:
The singleton McpClientManager instance
"""
logger.debug("Getting MCP Client Manager singleton")
return McpClientManager()
async def call_mcp_tool(tool_identifier: str, params: dict[str, Any], max_retries: int = 1) -> Any:
"""Convenience function for calling MCP tools using the singleton manager.
This is a high-level API that automatically uses the singleton manager instance.
On failure, automatically retries once before raising an error.
Args:
tool_identifier: Tool identifier in format "serverName__toolName"
params: Dictionary of parameters to pass to the tool
max_retries: Maximum number of retry attempts on failure (default: 1)
Returns:
The tool execution result
Raises:
ConfigurationError: If manager not initialized
ToolNotFoundError: If tool doesn't exist
ToolExecutionError: If tool execution fails after all retries
ServerConnectionError: If unable to connect to server
"""
manager = get_mcp_client_manager()
return await manager.call_tool(tool_identifier, params, max_retries=max_retries)
+151
View File
@@ -0,0 +1,151 @@
"""
Field normalization utilities for handling inconsistent API casing.
Some MCP servers (e.g., Azure DevOps) return fields with lowercase prefixes
but expect PascalCase prefixes in certain contexts. This module provides
configurable normalization strategies.
"""
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
# Type alias for normalization strategies
NormalizationStrategy = Literal["none", "ado-pascal-case"]
class NormalizationConfig(BaseModel):
"""Configuration for field normalization per server."""
model_config = ConfigDict(extra="forbid")
servers: dict[str, NormalizationStrategy]
# Default configuration
NORMALIZATION_CONFIG = NormalizationConfig(
servers={
"ado": "ado-pascal-case",
"filesystem": "none",
"github": "none",
}
)
def normalize_field_names(obj: Any, server_name: str) -> Any:
"""
Normalize field names based on server strategy.
Recursively traverses dicts and lists.
Returns new object (immutable).
Args:
obj: Object to normalize (dict, list, or primitive)
server_name: Name of the server (determines strategy)
Returns:
Normalized object (new instance, original unchanged)
Examples:
>>> normalize_field_names({"system.title": "foo"}, "ado")
{'System.Title': 'foo'}
>>> normalize_field_names({"title": "foo"}, "github")
{'title': 'foo'}
"""
strategy = NORMALIZATION_CONFIG.servers.get(server_name, "none")
if strategy == "none":
return obj
elif strategy == "ado-pascal-case":
return normalize_ado_fields(obj)
else:
# Unknown strategy, return unchanged
return obj
def normalize_ado_fields(obj: Any) -> Any:
"""
ADO-specific field normalization.
Rules:
- system.* → System.*
- microsoft.* → Microsoft.*
- custom.* → Custom.*
- wef_* → WEF_*
Recursively processes nested structures.
Returns new object (immutable).
Args:
obj: Object to normalize
Returns:
Normalized object (new instance)
Examples:
>>> normalize_ado_fields({"system.title": "foo"})
{'System.title': 'foo'}
>>> normalize_ado_fields({"fields": {"system.id": 123}})
{'fields': {'System.id': 123}}
"""
# Handle primitives
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
# Handle lists
if isinstance(obj, list):
return [normalize_ado_fields(item) for item in obj]
# Handle dicts
if isinstance(obj, dict):
normalized = {}
for key, value in obj.items():
new_key = key
# Apply normalization rules
if key.startswith("system."):
new_key = "System." + key[7:]
elif key.startswith("microsoft."):
new_key = "Microsoft." + key[10:]
elif key.startswith("custom."):
new_key = "Custom." + key[7:]
elif key.startswith("wef_"):
new_key = "WEF_" + key[4:]
# Recursively normalize value
normalized[new_key] = normalize_ado_fields(value)
return normalized
# Unknown type, return as-is
return obj
def update_normalization_config(server_name: str, strategy: NormalizationStrategy) -> None:
"""
Update normalization strategy for a server.
Args:
server_name: Name of the server
strategy: Normalization strategy to use
Examples:
>>> update_normalization_config("myserver", "ado-pascal-case")
>>> update_normalization_config("myserver", "none")
"""
NORMALIZATION_CONFIG.servers[server_name] = strategy
def get_normalization_strategy(server_name: str) -> NormalizationStrategy:
"""
Get normalization strategy for a server.
Args:
server_name: Name of the server
Returns:
Normalization strategy (defaults to "none")
"""
return NORMALIZATION_CONFIG.servers.get(server_name, "none")
View File
+168
View File
@@ -0,0 +1,168 @@
"""
Type inference utilities for discovering Pydantic models from API responses.
This module infers Pydantic models from actual response data when output
schemas are not available or incomplete.
"""
from typing import Any
def infer_python_type(value: Any) -> str:
"""
Infer Python type from a value.
Args:
value: The value to infer type from
Returns:
Python type hint string (e.g., "str", "int", "List[str]")
Examples:
>>> infer_python_type("hello")
'str'
>>> infer_python_type(42)
'int'
>>> infer_python_type([1, 2, 3])
'List[int]'
"""
if value is None:
return "Optional[Any]"
elif isinstance(value, bool):
return "bool"
elif isinstance(value, int):
return "int"
elif isinstance(value, float):
return "float"
elif isinstance(value, str):
return "str"
elif isinstance(value, list):
if not value:
return "List[Any]"
# Infer from first element
item_type = infer_python_type(value[0])
return f"List[{item_type}]"
elif isinstance(value, dict):
if not value:
return "Dict[str, Any]"
# Check if all values have same type
value_types = set(infer_python_type(v) for v in value.values())
if len(value_types) == 1:
value_type = value_types.pop()
return f"Dict[str, {value_type}]"
else:
return "Dict[str, Any]"
else:
return "Any"
def infer_pydantic_model_from_response(
tool_name: str,
response_data: Any,
description: str | None = None,
) -> str:
"""
Infer Pydantic model from actual response data.
All fields are marked Optional for defensive coding (handle missing data).
Args:
tool_name: Name of the tool that produced response
response_data: Actual response data from tool execution
description: Optional tool description
Returns:
Python code for Pydantic model
Example:
>>> response = {"name": "John", "age": 30, "tags": ["python", "mcp"]}
>>> code = infer_pydantic_model_from_response("get_user", response)
>>> print(code)
class GetUserResult(BaseModel):
'''Result from get_user tool.'''
name: Optional[str] = None
age: Optional[int] = None
tags: Optional[List[str]] = None
"""
# Normalize tool name for model name
model_name = "".join(word.capitalize() for word in tool_name.split("_")) + "Result"
if not isinstance(response_data, dict):
# Non-dict responses become wrapped
inferred_type = infer_python_type(response_data)
return f"""
class {model_name}(BaseModel):
'''Result from {tool_name} tool.'''
value: {inferred_type} = None
"""
# Build model fields from dict
lines = [f"class {model_name}(BaseModel):"]
# Add docstring
if description:
lines.append(f' """{description}"""')
else:
lines.append(f' """Result from {tool_name} tool."""')
# Generate fields (all Optional for defensive coding)
if not response_data:
lines.append(" pass")
else:
for key, value in response_data.items():
# Sanitize field name
field_name = key.replace("-", "_").replace(".", "_")
if field_name.startswith("_"):
field_name = field_name[1:]
inferred_type = infer_python_type(value)
# All fields Optional (defensive)
if inferred_type.startswith("Optional"):
lines.append(f" {field_name}: {inferred_type} = None")
else:
lines.append(f" {field_name}: Optional[{inferred_type}] = None")
return "\n".join(lines)
def merge_response_schemas(schemas: list[dict[str, Any]]) -> dict[str, str]:
"""
Merge multiple response schemas into unified field types.
When executing the same tool with different parameters, we may get
slightly different response structures. This merges them conservatively.
Args:
schemas: List of response schemas to merge
Returns:
Dict mapping field name to merged type hint
"""
if not schemas:
return {}
if len(schemas) == 1:
return {key: infer_python_type(value) for key, value in schemas[0].items()}
# Find all field names across all schemas
all_fields: set[str] = set()
for schema in schemas:
if isinstance(schema, dict):
all_fields.update(schema.keys())
# Merge types conservatively (use Any if types differ)
merged = {}
for field in all_fields:
field_types = set()
for schema in schemas:
if isinstance(schema, dict) and field in schema:
field_types.add(infer_python_type(schema[field]))
if len(field_types) == 1:
# Consistent type
merged[field] = field_types.pop()
else:
# Mixed types - use Any
merged[field] = "Any"
return merged
+215
View File
@@ -0,0 +1,215 @@
"""
JSON Schema to Pydantic model conversion utilities.
Converts MCP tool schemas (JSON Schema format) to Pydantic model definitions.
Uses dispatch tables to reduce cyclomatic complexity.
"""
from typing import Any, Callable
# ===========================================================================
# Dispatch Table: Maps JSON Schema types to Python type strings
# ===========================================================================
TYPE_MAPPING: dict[str, str] = {
"string": "str",
"number": "float",
"integer": "int",
"boolean": "bool",
"null": "None",
}
def _wrap_optional(base_type: str, required: bool) -> str:
"""Wrap type in Optional[] if not required."""
return base_type if required else f"Optional[{base_type}]"
def _handle_primitive_type(schema: dict[str, Any], required: bool) -> str:
"""Handle primitive JSON Schema types via dispatch table."""
schema_type = schema.get("type", "object")
base_type = TYPE_MAPPING.get(schema_type, "Any")
return _wrap_optional(base_type, required)
def _handle_array_type(schema: dict[str, Any], required: bool) -> str:
"""Handle JSON Schema array type."""
items_schema = schema.get("items", {"type": "object"})
item_type = json_schema_to_python_type(items_schema, required=True)
base_type = f"List[{item_type}]"
return _wrap_optional(base_type, required)
def _handle_object_type(schema: dict[str, Any], required: bool) -> str:
"""Handle JSON Schema object type."""
if "additionalProperties" in schema:
value_schema = schema["additionalProperties"]
if isinstance(value_schema, bool):
value_type = "Any"
else:
value_type = json_schema_to_python_type(value_schema, required=True)
base_type = f"Dict[str, {value_type}]"
return _wrap_optional(base_type, required)
return "Dict[str, Any]" if required else "Optional[Dict[str, Any]]"
def _handle_enum_type(schema: dict[str, Any], required: bool) -> str:
"""Handle JSON Schema enum type."""
enum_values = schema["enum"]
literal_values = ", ".join([f'"{v}"' for v in enum_values])
base_type = f"Literal[{literal_values}]"
return _wrap_optional(base_type, required)
def _handle_union_type(schema: dict[str, Any], required: bool) -> tuple[dict[str, Any], bool]:
"""Handle union types like ["string", "null"]. Returns updated schema and required."""
types = schema["type"]
if "null" in types:
required = False
types = [t for t in types if t != "null"]
if len(types) == 1:
schema = {"type": types[0]}
return schema, required
# Dispatch table for complex types that need special handling
COMPLEX_TYPE_HANDLERS: dict[str, Callable[[dict[str, Any], bool], str]] = {
"array": _handle_array_type,
"object": _handle_object_type,
}
def json_schema_to_python_type(schema: dict[str, Any], required: bool = True) -> str:
"""
Convert JSON Schema type to Python type hint string.
Uses dispatch tables to minimize cyclomatic complexity:
- TYPE_MAPPING for primitive types
- COMPLEX_TYPE_HANDLERS for array/object types
Args:
schema: JSON Schema definition
required: Whether field is required
Returns:
Python type hint string (e.g., "str", "Optional[int]", "List[str]")
Examples:
>>> json_schema_to_python_type({"type": "string"}, True)
'str'
>>> json_schema_to_python_type({"type": "string"}, False)
'Optional[str]'
>>> json_schema_to_python_type({"type": "array", "items": {"type": "string"}})
'List[str]'
"""
# Handle union types: ["string", "null"]
if isinstance(schema.get("type"), list):
schema, required = _handle_union_type(schema, required)
# Handle enum first (takes priority)
if "enum" in schema:
return _handle_enum_type(schema, required)
# Get schema type
schema_type = schema.get("type", "object")
# Dispatch to primitive type handler
if schema_type in TYPE_MAPPING:
return _handle_primitive_type(schema, required)
# Dispatch to complex type handler
if schema_type in COMPLEX_TYPE_HANDLERS:
return COMPLEX_TYPE_HANDLERS[schema_type](schema, required)
# Fallback for unknown types
return _wrap_optional("Any", required)
def generate_pydantic_model(
model_name: str,
schema: dict[str, Any],
description: str | None = None,
) -> str:
"""
Generate Pydantic model class from JSON Schema.
Args:
model_name: Name of the Pydantic model class
schema: JSON Schema definition
description: Optional model description
Returns:
Python code for Pydantic model
Example:
>>> schema = {
... "type": "object",
... "properties": {
... "name": {"type": "string"},
... "age": {"type": "integer"}
... },
... "required": ["name"]
... }
>>> print(generate_pydantic_model("Person", schema))
class Person(BaseModel):
'''Generated model'''
name: str
age: Optional[int] = None
"""
properties = schema.get("properties", {})
required_fields = set(schema.get("required", []))
lines = [f"class {model_name}(BaseModel):"]
# Add docstring
if description:
lines.append(f' """{description}"""')
else:
lines.append(' """Generated Pydantic model."""')
# Generate fields
if not properties:
lines.append(" pass")
else:
for field_name, field_schema in properties.items():
is_required = field_name in required_fields
field_type = json_schema_to_python_type(field_schema, is_required)
field_desc = field_schema.get("description", "")
if is_required:
lines.append(f" {field_name}: {field_type}")
else:
lines.append(f" {field_name}: {field_type} = None")
if field_desc:
lines.append(f' """{field_desc}"""')
return "\n".join(lines)
def sanitize_name(name: str) -> str:
"""
Sanitize name for Python identifier.
Args:
name: Original name
Returns:
Valid Python identifier
Examples:
>>> sanitize_name("my-tool")
'my_tool'
>>> sanitize_name("list")
'list_'
"""
# Replace hyphens with underscores
name = name.replace("-", "_").replace(".", "_")
# Handle Python keywords
python_keywords = {"list", "dict", "set", "type", "class", "def", "import"}
if name in python_keywords:
name = name + "_"
return name