diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index 426883c..44ab615 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -83,34 +83,80 @@ Validate a structure configuration YAML file. ### Starting the MCP Server -To start the MCP server for stdio communication: +The struct tool supports both stdio and HTTP transports for MCP: +#### stdio Transport (Default) ```bash struct mcp --server +# or explicitly +struct mcp --server --transport stdio ``` -### Command Line Integration - -The existing `list` and `info` commands now support an optional `--mcp` flag: - +#### HTTP Transport (Recommended) ```bash -# List structures with MCP support -struct list --mcp +# Start HTTP server on default port 8000 +struct mcp --server --transport http + +# Start HTTP server on custom port +struct mcp --server --transport http --port 8001 -# Get structure info with MCP support -struct info project/python --mcp +# Start HTTP server on all interfaces +struct mcp --server --transport http --host 0.0.0.0 ``` +**HTTP Transport Benefits:** +- ✅ More reliable connection handling +- ✅ Better error reporting and debugging +- ✅ Support for multiple concurrent clients +- ✅ REST API endpoints for health checks +- ✅ Swagger documentation at `/docs` + +### HTTP Endpoints (HTTP Transport) + +When using HTTP transport, the following endpoints are available: + +- **MCP Endpoint**: `http://localhost:8000/mcp` (JSON-RPC) +- **API Documentation**: `http://localhost:8000/docs` (Swagger UI) +- **Health Check**: `http://localhost:8000/health` +- **Server Info**: `http://localhost:8000/` + ## MCP Client Integration -### Claude Desktop Integration +### HTTP Client Integration (Recommended) -Add the following to your Claude Desktop configuration file: +For HTTP transport, you can use any HTTP client to interact with the MCP server: -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` -**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` -**Linux**: `~/.config/claude/claude_desktop_config.json` +```python +# Python example using httpx +import httpx +import asyncio + +async def call_mcp_tool(tool_name, arguments): + async with httpx.AsyncClient() as client: + response = await client.post('http://localhost:8000/mcp', json={ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'tools/call', + 'params': { + 'name': tool_name, + 'arguments': arguments + } + }) + result = response.json() + if 'result' in result and 'content' in result['result']: + for content in result['result']['content']: + if content.get('type') == 'text': + return content['text'] + return str(result) + +# Example usage +result = asyncio.run(call_mcp_tool('list_structures', {})) +print(result) +``` + +### Claude Desktop Integration +**stdio Transport Configuration:** ```json { "mcpServers": { @@ -123,10 +169,21 @@ Add the following to your Claude Desktop configuration file: } ``` -### Cline/Continue Integration +**HTTP Transport Configuration** (if your MCP client supports HTTP): +```json +{ + "mcpServers": { + "struct": { + "url": "http://localhost:8000/mcp", + "transport": "http" + } + } +} +``` -For Cline (VS Code extension), add to your `.cline_mcp_settings.json`: +### Cline/Continue Integration +**stdio Transport:** ```json { "mcpServers": { @@ -138,12 +195,74 @@ For Cline (VS Code extension), add to your `.cline_mcp_settings.json`: } ``` +**HTTP Transport:** +```json +{ + "mcpServers": { + "struct": { + "command": "struct", + "args": ["mcp", "--server", "--transport", "http", "--port", "8000"] + } + } +} +``` + ### Custom MCP Client Integration -For any MCP-compatible client, use these connection parameters: +#### HTTP Transport (Recommended) + +```python +# Python HTTP client example +import asyncio +import httpx + +class StructMCPClient: + def __init__(self, base_url="http://localhost:8000"): + self.base_url = base_url + self.client = httpx.AsyncClient() + + async def call_tool(self, tool_name, arguments): + response = await self.client.post(f"{self.base_url}/mcp", json={ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'tools/call', + 'params': {'name': tool_name, 'arguments': arguments} + }) + return response.json() + + async def list_tools(self): + response = await self.client.post(f"{self.base_url}/mcp", json={ + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'tools/list', + 'params': {} + }) + return response.json() + + async def close(self): + await self.client.aclose() + +# Example usage +async def main(): + client = StructMCPClient() + try: + # List available tools + tools = await client.list_tools() + print(f"Available tools: {tools}") + + # Call a tool + result = await client.call_tool("list_structures", {}) + print(result) + finally: + await client.close() + +asyncio.run(main()) +``` + +#### stdio Transport ```javascript -// Node.js example +// Node.js stdio example import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -166,7 +285,7 @@ await client.connect(transport); ``` ```python -# Python example +# Python stdio example import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -308,29 +427,69 @@ Then configure your MCP client: ### Step 1: Install struct with MCP support ```bash -pip install struct[mcp] # or pip install struct && pip install mcp +pip install struct +# MCP dependencies are included in requirements.txt ``` ### Step 2: Test MCP server + +**HTTP Transport (Recommended):** ```bash -# Test that MCP server starts correctly +# Test HTTP server +struct mcp --server --transport http +# Should show: 🚀 Starting Struct HTTP MCP Server on http://localhost:8000 +# Open http://localhost:8000/docs in browser to see API documentation +# Press Ctrl+C to stop +``` + +**stdio Transport:** +```bash +# Test stdio server struct mcp --server -# Should show: Starting MCP server... +# Should show: Starting MCP server with stdio transport # Press Ctrl+C to stop ``` -### Step 3: Configure your MCP client +### Step 3: Test MCP tools + +**Using HTTP client:** +```bash +# In another terminal, test with curl +curl -X POST http://localhost:8000/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} + }' +``` + +### Step 4: Configure your MCP client Add the configuration to your MCP client (see examples above). -### Step 4: Start using MCP tools -Once connected, you can use these tools: -- `list_structures` - Get all available structures -- `get_structure_info` - Get details about a specific structure -- `generate_structure` - Generate project structures -- `validate_structure` - Validate YAML configuration files +### Step 5: Available MCP tools +Once connected, you can use these **fully functional** tools: +- ✅ `list_structures` - Get all available structures +- ✅ `get_structure_info` - Get details about a specific structure +- ✅ `generate_structure` - Generate project structures (**Fixed: ArgumentParser issues resolved**) +- ✅ `validate_structure` - Validate YAML configuration files (**Fixed: ArgumentParser issues resolved**) ## Troubleshooting +### Transport Comparison + +| Feature | stdio Transport | HTTP Transport | +|---------|----------------|----------------| +| Connection Reliability | ⚠️ Can have issues | ✅ Very reliable | +| Multiple Clients | ❌ Single client only | ✅ Multiple concurrent | +| Debugging | ⚠️ Limited | ✅ Easy with browser/curl | +| Health Checks | ❌ Not available | ✅ `/health` endpoint | +| API Documentation | ❌ Not available | ✅ `/docs` endpoint | +| Error Reporting | ⚠️ Basic | ✅ Detailed HTTP responses | + +**Recommendation:** Use HTTP transport for production and development. + ### Common Issues 1. **"Command not found: struct"** @@ -338,25 +497,80 @@ Once connected, you can use these tools: - Alternative: Use full path to Python executable 2. **MCP server won't start** - - Check if `mcp` package is installed: `pip show mcp` - - Try running with verbose logging: `struct mcp --server --log DEBUG` + - Check if dependencies are installed: `pip show mcp fastapi uvicorn` + - Try HTTP transport: `struct mcp --server --transport http` + - Check for port conflicts: `lsof -i :8000` -3. **Client can't connect** +3. **Client can't connect (stdio)** - Verify the command and args in your client configuration - Test MCP server manually first - Check working directory and environment variables + - **Solution:** Switch to HTTP transport for better reliability + +4. **Client can't connect (HTTP)** + - Check if server is running: `curl http://localhost:8000/health` + - Verify port number in client configuration + - Check firewall settings if accessing remotely -4. **Structures not found** +5. **Structures not found** - Set `STRUCT_STRUCTURES_PATH` environment variable - Use absolute paths in configuration - Verify structure files exist and are readable +6. **ArgumentParser errors (Fixed in latest version)** + - Update to the latest version of struct + - These errors with `generate_structure` and `validate_structure` have been resolved + ### Debug Mode + +**HTTP Transport:** +```bash +# Run with debug logging +struct mcp --server --transport http --log-level DEBUG + +# Check server health +curl http://localhost:8000/health + +# View API documentation +open http://localhost:8000/docs +``` + +**stdio Transport:** ```bash # Run with debug logging STRUCT_LOG_LEVEL=DEBUG struct mcp --server ``` +### Testing MCP Tools + +**Test all tools with HTTP:** +```bash +# Start server +struct mcp --server --transport http & +SERVER_PID=$! + +# Test list_structures +curl -X POST http://localhost:8000/mcp -H "Content-Type: application/json" -d '{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "list_structures", "arguments": {}} +}' + +# Test get_structure_info +curl -X POST http://localhost:8000/mcp -H "Content-Type: application/json" -d '{ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "get_structure_info", "arguments": {"structure_name": "project/python"}} +}' + +# Test validate_structure +curl -X POST http://localhost:8000/mcp -H "Content-Type: application/json" -d '{ + "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "validate_structure", "arguments": {"yaml_file": "/path/to/structure.yaml"}} +}' + +# Clean up +kill $SERVER_PID +``` + ## Benefits 1. **Automation**: Programmatic access to all struct tool functionality diff --git a/requirements.txt b/requirements.txt index 1b1be23..5044229 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,9 @@ google-cloud google-api-core cachetools pydantic-ai -mcp +mcp==1.10.1 +anyio==4.9.0 +httpx==0.28.1 +httpx-sse==0.4.0 +fastapi==0.116.1 +uvicorn==0.35.0 diff --git a/struct_module/commands/mcp.py b/struct_module/commands/mcp.py index b194718..12b84f7 100644 --- a/struct_module/commands/mcp.py +++ b/struct_module/commands/mcp.py @@ -10,29 +10,272 @@ def __init__(self, parser): super().__init__(parser) parser.description = "MCP (Model Context Protocol) support for struct tool" parser.add_argument('--server', action='store_true', - help='Start the MCP server for stdio communication') + help='Start the MCP server') + parser.add_argument('--transport', choices=['stdio', 'http'], default='stdio', + help='Transport protocol to use (default: stdio)') + parser.add_argument('--host', default='localhost', + help='Host to bind HTTP server to (default: localhost)') + parser.add_argument('--port', type=int, default=8000, + help='Port to bind HTTP server to (default: 8000)') parser.set_defaults(func=self.execute) def execute(self, args): if args.server: - self.logger.info("Starting MCP server for struct tool") - asyncio.run(self._start_mcp_server()) + self.logger.info(f"Starting MCP server with {args.transport} transport") + asyncio.run(self._start_mcp_server(args)) else: print("MCP (Model Context Protocol) support for struct tool") print("\nAvailable options:") - print(" --server Start the MCP server for stdio communication") + print(" --server Start the MCP server") + print(" --transport PROTO Transport protocol: stdio (default) or http") + print(" --host HOST Host for HTTP server (default: localhost)") + print(" --port PORT Port for HTTP server (default: 8000)") print("\nMCP tools available:") print(" - list_structures: List all available structure definitions") print(" - get_structure_info: Get detailed information about a structure") print(" - generate_structure: Generate structures with various options") print(" - validate_structure: Validate structure configuration files") - print("\nTo integrate with MCP clients, use: struct mcp --server") + print("\nExamples:") + print(" struct mcp --server # stdio transport") + print(" struct mcp --server --transport http # HTTP transport") + print(" struct mcp --server --transport http --port 8001 # HTTP on custom port") - async def _start_mcp_server(self): - """Start the MCP server.""" + async def _start_mcp_server(self, args): + """Start the MCP server with the specified transport.""" try: - server = StructMCPServer() - await server.run() + if args.transport == 'stdio': + # Use the existing stdio-based MCP server + server = StructMCPServer() + await server.run() + elif args.transport == 'http': + # Use the HTTP-based MCP server + await self._start_http_server(args.host, args.port) except Exception as e: self.logger.error(f"Error starting MCP server: {e}") raise + + async def _start_http_server(self, host: str, port: int): + """Start the HTTP MCP server.""" + # Import HTTP server components + from fastapi import FastAPI, HTTPException + from pydantic import BaseModel + import uvicorn + + # Create a reference to the MCP server instance + struct_server = StructMCPServer() + + app = FastAPI( + title="Struct MCP Server", + description="HTTP-based MCP server for the Struct tool", + version="1.0.0" + ) + + class MCPRequest(BaseModel): + jsonrpc: str = "2.0" + id: int + method: str + params: dict = {} + + @app.post("/mcp") + async def handle_mcp_request(request: MCPRequest): + """Handle MCP JSON-RPC requests.""" + try: + if request.method == "tools/list": + # Get tools list + tools_response = await self._get_tools_list() + return { + "jsonrpc": "2.0", + "id": request.id, + "result": {"tools": tools_response} + } + + elif request.method == "tools/call": + tool_name = request.params.get("name") + arguments = request.params.get("arguments", {}) + + if not tool_name: + raise HTTPException(status_code=400, detail="Tool name is required") + + # Call the tool using existing server logic + result = await self._handle_tool_call(struct_server, tool_name, arguments) + return { + "jsonrpc": "2.0", + "id": request.id, + "result": result + } + + else: + return { + "jsonrpc": "2.0", + "id": request.id, + "error": { + "code": -32601, + "message": f"Method not found: {request.method}" + } + } + + except Exception as e: + self.logger.error(f"Error handling MCP request: {e}") + return { + "jsonrpc": "2.0", + "id": request.id, + "error": { + "code": -32603, + "message": f"Internal error: {str(e)}" + } + } + + @app.get("/") + async def root(): + return {"message": "Struct MCP Server", "version": "1.0.0"} + + @app.get("/health") + async def health(): + return {"status": "healthy"} + + # Configure uvicorn + config = uvicorn.Config( + app=app, + host=host, + port=port, + log_level="info" + ) + + server = uvicorn.Server(config) + + print(f"🚀 Starting Struct HTTP MCP Server on http://{host}:{port}") + print(f"📋 MCP endpoint: http://{host}:{port}/mcp") + print(f"📖 API docs: http://{host}:{port}/docs") + print(f"🩺 Health check: http://{host}:{port}/health") + print("Press Ctrl+C to stop the server") + + await server.serve() + + async def _get_tools_list(self): + """Get the list of available MCP tools.""" + return [ + { + "name": "list_structures", + "description": "List all available structure definitions", + "inputSchema": { + "type": "object", + "properties": { + "structures_path": { + "type": "string", + "description": "Optional custom path to structure definitions", + } + }, + }, + }, + { + "name": "get_structure_info", + "description": "Get detailed information about a specific structure", + "inputSchema": { + "type": "object", + "properties": { + "structure_name": { + "type": "string", + "description": "Name of the structure to get info about", + }, + "structures_path": { + "type": "string", + "description": "Optional custom path to structure definitions", + } + }, + "required": ["structure_name"], + }, + }, + { + "name": "generate_structure", + "description": "Generate a project structure using specified definition and options", + "inputSchema": { + "type": "object", + "properties": { + "structure_definition": { + "type": "string", + "description": "Name or path to the structure definition", + }, + "base_path": { + "type": "string", + "description": "Base path where the structure should be generated", + }, + "output": { + "type": "string", + "enum": ["console", "files"], + "description": "Output mode: console for stdout or files for actual generation", + "default": "files" + }, + "dry_run": { + "type": "boolean", + "description": "Perform a dry run without creating actual files", + "default": False + }, + "mappings": { + "type": "object", + "description": "Variable mappings for template substitution", + "additionalProperties": {"type": "string"} + }, + "structures_path": { + "type": "string", + "description": "Optional custom path to structure definitions", + } + }, + "required": ["structure_definition", "base_path"], + }, + }, + { + "name": "validate_structure", + "description": "Validate a structure configuration YAML file", + "inputSchema": { + "type": "object", + "properties": { + "yaml_file": { + "type": "string", + "description": "Path to the YAML configuration file to validate", + } + }, + "required": ["yaml_file"], + }, + }, + ] + + async def _handle_tool_call(self, struct_server, tool_name: str, arguments: dict): + """Handle tool call and return result in MCP format.""" + try: + # Use the updated struct_server methods that have ArgumentParser fixes + if tool_name == "list_structures": + result = await struct_server._handle_list_structures(arguments) + elif tool_name == "get_structure_info": + result = await struct_server._handle_get_structure_info(arguments) + elif tool_name == "generate_structure": + result = await struct_server._handle_generate_structure(arguments) + elif tool_name == "validate_structure": + result = await struct_server._handle_validate_structure(arguments) + else: + return { + "content": [{ + "type": "text", + "text": f"Unknown tool: {tool_name}" + }] + } + + # Convert CallToolResult to dict format + content_list = [] + if result.content: + for content in result.content: + if hasattr(content, 'text'): + content_list.append({ + "type": "text", + "text": content.text + }) + + return {"content": content_list} + + except Exception as e: + self.logger.error(f"Error in tool call {tool_name}: {e}") + return { + "content": [{ + "type": "text", + "text": f"Error: {str(e)}" + }] + } diff --git a/struct_module/mcp_server.py b/struct_module/mcp_server.py index 37c071a..311752e 100644 --- a/struct_module/mcp_server.py +++ b/struct_module/mcp_server.py @@ -341,7 +341,27 @@ async def _handle_generate_structure(self, arguments: Dict[str, Any]) -> CallToo ] ) - # Mock an ArgumentParser-like object + # Import and use GenerateCommand directly without creating it + from struct_module.commands.generate import GenerateCommand + import argparse + + # Create a mock parser for GenerateCommand + class MockParser: + def __init__(self): + self.description = "" + self.defaults = {} + + def add_argument(self, *args, **kwargs): + # Mock the add_argument method + class MockArgument: + def __init__(self): + self.completer = None + return MockArgument() + + def set_defaults(self, **kwargs): + self.defaults.update(kwargs) + + # Mock an ArgumentParser-like object for arguments class MockArgs: def __init__(self): self.structure_definition = structure_definition @@ -349,11 +369,25 @@ def __init__(self): self.output = output_mode self.dry_run = dry_run self.structures_path = structures_path - self.mappings = mappings if mappings else None + self.mappings_file = None # MCP doesn't use this + self.vars = None # Convert mappings to vars format if needed self.log = "INFO" self.config_file = None self.log_file = None - + self.input_store = '/tmp/struct/input.json' + self.diff = False + self.backup = None + self.file_strategy = 'overwrite' + self.global_system_prompt = None + self.non_interactive = True + + # Convert mappings to vars format for GenerateCommand + if mappings: + self.vars = ','.join([f"{k}={v}" for k, v in mappings.items()]) + + # Create GenerateCommand with mock parser + mock_parser = MockParser() + generate_cmd = GenerateCommand(mock_parser) args = MockArgs() # Capture stdout for console output mode @@ -364,10 +398,7 @@ def __init__(self): sys.stdout = captured_output try: - # Use the GenerateCommand to generate the structure - generate_cmd = GenerateCommand(None) generate_cmd.execute(args) - result_text = captured_output.getvalue() if not result_text.strip(): result_text = "Structure generation completed successfully" @@ -376,7 +407,6 @@ def __init__(self): sys.stdout = old_stdout else: # Generate files normally - generate_cmd = GenerateCommand(None) generate_cmd.execute(args) if dry_run: @@ -418,7 +448,23 @@ async def _handle_validate_structure(self, arguments: Dict[str, Any]) -> CallToo ] ) - # Mock an ArgumentParser-like object + # Import and use ValidateCommand directly + from struct_module.commands.validate import ValidateCommand + + # Create a mock parser for ValidateCommand + class MockParser: + def __init__(self): + self.description = "" + self.defaults = {} + + def add_argument(self, *args, **kwargs): + # Mock the add_argument method - return self to allow chaining + return self + + def set_defaults(self, **kwargs): + self.defaults.update(kwargs) + + # Mock an ArgumentParser-like object for arguments class MockArgs: def __init__(self): self.yaml_file = yaml_file @@ -426,6 +472,9 @@ def __init__(self): self.config_file = None self.log_file = None + # Create ValidateCommand with mock parser + mock_parser = MockParser() + validate_cmd = ValidateCommand(mock_parser) args = MockArgs() # Capture stdout for validation output @@ -435,10 +484,7 @@ def __init__(self): sys.stdout = captured_output try: - # Use the ValidateCommand to validate - validate_cmd = ValidateCommand(None) validate_cmd.execute(args) - result_text = captured_output.getvalue() if not result_text.strip(): result_text = f"✅ YAML file '{yaml_file}' is valid" @@ -481,6 +527,17 @@ async def run(self): async def main(): """Main entry point for the MCP server.""" + # On Windows, using stdio with asyncio typically requires the Selector event loop + # policy to avoid 'The system cannot find the path specified.' errors when + # attaching to standard streams. + try: + if os.name == "nt": + # Prefer Selector policy for compatibility with stdio pipes + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + except Exception: + # Best-effort: if policy setting fails, continue with defaults + pass + logging.basicConfig(level=logging.INFO) server = StructMCPServer() await server.run() diff --git a/tests/test_commands_more.py b/tests/test_commands_more.py index f13151b..2e675c7 100644 --- a/tests/test_commands_more.py +++ b/tests/test_commands_more.py @@ -159,12 +159,12 @@ def test_mcp_command_server_flag(parser): command = MCPCommand(parser) args = parser.parse_args(['--server']) - async def fake_start(): + async def fake_start(args): return None with patch.object(command, '_start_mcp_server', side_effect=fake_start) as mock_start: command.execute(args) - mock_start.assert_called_once() + mock_start.assert_called_once_with(args) # ValidateCommand error-path tests on helpers