diff --git a/MCP_INTEGRATION.md b/MCP_INTEGRATION.md new file mode 100644 index 0000000..f407c98 --- /dev/null +++ b/MCP_INTEGRATION.md @@ -0,0 +1,135 @@ +# MCP Integration Guide + +Nomad MCP provides seamless integration with AI assistants through the Model Context Protocol (MCP), enabling AI agents to interact with your Nomad cluster directly. + +## What is the Model Context Protocol (MCP)? + +The Model Context Protocol (MCP) is a standardized way for AI agents to interact with external tools and services. It allows AI models to call specific functions and receive structured responses, which they can then incorporate into their reasoning and responses. + +## Zero-Config MCP Integration + +Nomad MCP uses FastAPI MCP to automatically expose all API endpoints as MCP tools with zero configuration. This means that all endpoints in the REST API are immediately available as MCP tools without any manual definition or configuration. + +### Connection Endpoint + +AI assistants can connect to the MCP endpoint at: + +``` +http://your-server:8000/mcp/sse +``` + +The SSE (Server-Sent Events) transport is used for communication between the AI agent and the MCP server. + +### Available Tools + +All the endpoints in the following routers are automatically exposed as MCP tools: + +- **Jobs**: Managing Nomad jobs (start, stop, restart, etc.) +- **Logs**: Retrieving job and allocation logs +- **Configs**: Managing job configurations +- **Repositories**: Working with code repositories + +Each endpoint is converted to an MCP tool with: +- Proper parameter validation +- Detailed descriptions +- Type information +- Example values + +### Example MCP Interactions + +Here are some examples of how an AI agent might use the MCP tools: + +#### Listing Jobs + +```json +{ + "type": "tool_call", + "content": { + "name": "list_jobs", + "parameters": { + "namespace": "development" + } + } +} +``` + +#### Getting Job Status + +```json +{ + "type": "tool_call", + "content": { + "name": "get_job_status", + "parameters": { + "job_id": "my-service" + } + } +} +``` + +#### Starting a Job + +```json +{ + "type": "tool_call", + "content": { + "name": "start_job", + "parameters": { + "job_id": "my-service", + "namespace": "development" + } + } +} +``` + +## Setting Up Claude with MCP + +### Claude Code Integration + +Claude Code can directly connect to the MCP endpoint at `http://your-server:8000/mcp/sse`. Use the `--mcp-url` flag when starting Claude Code: + +```bash +claude-code --mcp-url http://your-server:8000/mcp/sse +``` + +### Claude API Integration + +For integration with the Claude API, you can use the MCP toolchain configuration provided in the `claude_nomad_tool.json` file. + +See the [Claude API Integration Documentation](CLAUDE_API_INTEGRATION.md) for more detailed instructions. + +## Debugging MCP Connections + +If you're having issues with MCP connections: + +1. Check the server logs for connection attempts and errors +2. Verify that the `BASE_URL` environment variable is correctly set +3. Ensure the AI agent has network access to the MCP endpoint +4. Check that the correct MCP endpoint URL is being used +5. Verify the AI agent supports the SSE transport for MCP + +## Custom Tool Configurations + +While the zero-config approach automatically exposes all endpoints, you can customize the MCP tools by modifying the FastAPI MCP initialization in `app/main.py`: + +```python +mcp = FastApiMCP( + app, + base_url=base_url, + name="Nomad MCP Tools", + description="Tools for managing Nomad jobs via MCP protocol", + include_tags=["jobs", "logs", "configs", "repositories"], + # Add custom configurations here +) +``` + +## Security Considerations + +The MCP endpoint provides powerful capabilities for managing your Nomad cluster. Consider implementing: + +1. Authentication for the MCP endpoint +2. Proper network isolation +3. Role-based access control +4. Audit logging for MCP interactions + +By default, the MCP endpoint is accessible without authentication. In production environments, you should implement appropriate security measures. \ No newline at end of file diff --git a/README.md b/README.md index 2c0e375..3f61bd0 100644 Binary files a/README.md and b/README.md differ diff --git a/app/main.py b/app/main.py index 4b00a47..d8cc3c0 100644 --- a/app/main.py +++ b/app/main.py @@ -4,10 +4,12 @@ from fastapi.staticfiles import StaticFiles import os import logging from dotenv import load_dotenv +from fastapi_mcp import FastApiMCP from app.routers import jobs, logs, configs, repositories, claude from app.services.nomad_client import get_nomad_client from app.services.gitea_client import GiteaClient +from app.schemas.claude_api import McpRequest, McpResponse # Load environment variables load_dotenv() @@ -42,6 +44,17 @@ app.include_router(configs.router, prefix="/api/configs", tags=["configs"]) app.include_router(repositories.router, prefix="/api/repositories", tags=["repositories"]) app.include_router(claude.router, prefix="/api/claude", tags=["claude"]) +# Initialize the FastAPI MCP +base_url = os.getenv("BASE_URL", "http://localhost:8000") +mcp = FastApiMCP( + app, + base_url=base_url, + name="Nomad MCP Tools", + description="Tools for managing Nomad jobs via MCP protocol", + include_tags=["jobs", "logs", "configs", "repositories"], +) +mcp.mount() + @app.get("/api/health", tags=["health"]) async def health_check(): """Health check endpoint.""" diff --git a/app/schemas/claude_api.py b/app/schemas/claude_api.py index 74050e1..44324df 100644 --- a/app/schemas/claude_api.py +++ b/app/schemas/claude_api.py @@ -1,78 +1,92 @@ -from pydantic import BaseModel, Field -from typing import Dict, Any, List, Optional, Union - - -class ClaudeJobRequest(BaseModel): - """Request model for Claude to start or manage a job""" - job_id: str = Field(..., description="The ID of the job to manage") - action: str = Field(..., description="Action to perform: start, stop, restart, status") - namespace: Optional[str] = Field("development", description="Nomad namespace") - purge: Optional[bool] = Field(False, description="Whether to purge the job when stopping") - - -class ClaudeJobSpecification(BaseModel): - """Simplified job specification for Claude to create a new job""" - job_id: str = Field(..., description="The ID for the new job") - name: Optional[str] = Field(None, description="Name of the job (defaults to job_id)") - type: str = Field("service", description="Job type: service, batch, or system") - datacenters: List[str] = Field(["jm"], description="List of datacenters") - namespace: str = Field("development", description="Nomad namespace") - docker_image: str = Field(..., description="Docker image to run") - count: int = Field(1, description="Number of instances to run") - cpu: int = Field(100, description="CPU resources in MHz") - memory: int = Field(128, description="Memory in MB") - ports: Optional[List[Dict[str, Any]]] = Field(None, description="Port mappings") - env_vars: Optional[Dict[str, str]] = Field(None, description="Environment variables") - - def to_nomad_job_spec(self) -> Dict[str, Any]: - """Convert to Nomad job specification format""" - # Create a task with the specified Docker image - task = { - "Name": "app", - "Driver": "docker", - "Config": { - "image": self.docker_image, - }, - "Resources": { - "CPU": self.cpu, - "MemoryMB": self.memory - } - } - - # Add environment variables if specified - if self.env_vars: - task["Env"] = self.env_vars - - # Create network configuration - network = {} - if self.ports: - network["DynamicPorts"] = self.ports - task["Config"]["ports"] = [port["Label"] for port in self.ports] - - # Create the full job specification - job_spec = { - "ID": self.job_id, - "Name": self.name or self.job_id, - "Type": self.type, - "Datacenters": self.datacenters, - "Namespace": self.namespace, - "TaskGroups": [ - { - "Name": "app", - "Count": self.count, - "Tasks": [task], - "Networks": [network] if network else [] - } - ] - } - - return job_spec - - -class ClaudeJobResponse(BaseModel): - """Response model for Claude job operations""" - success: bool = Field(..., description="Whether the operation was successful") - job_id: str = Field(..., description="The ID of the job") - status: str = Field(..., description="Current status of the job") - message: str = Field(..., description="Human-readable message about the operation") - details: Optional[Dict[str, Any]] = Field(None, description="Additional details about the job") \ No newline at end of file +from pydantic import BaseModel, Field +from typing import Dict, Any, List, Optional, Union + + +class ClaudeJobRequest(BaseModel): + """Request model for Claude to start or manage a job""" + job_id: str = Field(..., description="The ID of the job to manage") + action: str = Field(..., description="Action to perform: start, stop, restart, status") + namespace: Optional[str] = Field("development", description="Nomad namespace") + purge: Optional[bool] = Field(False, description="Whether to purge the job when stopping") + + +class ClaudeJobSpecification(BaseModel): + """Simplified job specification for Claude to create a new job""" + job_id: str = Field(..., description="The ID for the new job") + name: Optional[str] = Field(None, description="Name of the job (defaults to job_id)") + type: str = Field("service", description="Job type: service, batch, or system") + datacenters: List[str] = Field(["jm"], description="List of datacenters") + namespace: str = Field("development", description="Nomad namespace") + docker_image: str = Field(..., description="Docker image to run") + count: int = Field(1, description="Number of instances to run") + cpu: int = Field(100, description="CPU resources in MHz") + memory: int = Field(128, description="Memory in MB") + ports: Optional[List[Dict[str, Any]]] = Field(None, description="Port mappings") + env_vars: Optional[Dict[str, str]] = Field(None, description="Environment variables") + + def to_nomad_job_spec(self) -> Dict[str, Any]: + """Convert to Nomad job specification format""" + # Create a task with the specified Docker image + task = { + "Name": "app", + "Driver": "docker", + "Config": { + "image": self.docker_image, + }, + "Resources": { + "CPU": self.cpu, + "MemoryMB": self.memory + } + } + + # Add environment variables if specified + if self.env_vars: + task["Env"] = self.env_vars + + # Create network configuration + network = {} + if self.ports: + network["DynamicPorts"] = self.ports + task["Config"]["ports"] = [port["Label"] for port in self.ports] + + # Create the full job specification + job_spec = { + "ID": self.job_id, + "Name": self.name or self.job_id, + "Type": self.type, + "Datacenters": self.datacenters, + "Namespace": self.namespace, + "TaskGroups": [ + { + "Name": "app", + "Count": self.count, + "Tasks": [task], + "Networks": [network] if network else [] + } + ] + } + + return job_spec + + +class ClaudeJobResponse(BaseModel): + """Response model for Claude job operations""" + success: bool = Field(..., description="Whether the operation was successful") + job_id: str = Field(..., description="The ID of the job") + status: str = Field(..., description="Current status of the job") + message: str = Field(..., description="Human-readable message about the operation") + details: Optional[Dict[str, Any]] = Field(None, description="Additional details about the job") + + +class McpRequest(BaseModel): + """Model for MCP protocol requests""" + id: str = Field(..., description="Unique identifier for the request") + type: str = Field(..., description="Type of request") + content: Dict[str, Any] = Field(..., description="Request content") + + +class McpResponse(BaseModel): + """Model for MCP protocol responses""" + id: str = Field(..., description="Unique identifier matching the request") + type: str = Field(..., description="Type of response: ack, result, error, done") + content: Optional[Dict[str, Any]] = Field(None, description="Response content") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f2832d7..368e437 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ -fastapi -uvicorn -python-nomad -pydantic -python-dotenv -httpx -python-multipart -pyyaml -requests \ No newline at end of file +fastapi +uvicorn +python-nomad +pydantic +python-dotenv +httpx +python-multipart +pyyaml +requests +fastapi_mcp \ No newline at end of file