mirror of
https://github.com/parcadei/Continuous-Claude-v3.git
synced 2026-09-14 20:06:55 +08:00
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:
+2
-1
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user