diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..685787d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,27 @@ +# CLAUDE.md - Guide for AI Coding Agents + +## Project Overview +Nomad MCP is a service that enables management of HashiCorp Nomad jobs via REST API, with Claude AI integration. + +## Commands +- **Run server**: `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` +- **Tests**: `pytest` (all) or `pytest tests/test_nomad_service.py::test_job_lifecycle` (single) +- **Build docker**: `docker build -t nomad-mcp .` +- **Run docker**: `docker-compose up -d` + +## Code Style +- **Imports**: Standard library → Third-party → Local modules (alphabetically) +- **Type annotations**: Required for all function parameters and returns +- **Error handling**: Use try/except with proper logging and HTTP exceptions +- **Logging**: Use Python's logging module with appropriate levels +- **API responses**: Return consistent JSON structures with Pydantic models +- **Docstrings**: Required for all functions and classes +- **Variables**: snake_case for variables, CamelCase for classes + +## Structure +- `/app`: Main code (/routers, /schemas, /services) +- `/configs`: Configuration files +- `/static`: Frontend assets +- `/tests`: Test files + +Always maintain backward compatibility with existing API endpoints. Follow REST principles. \ No newline at end of file diff --git a/CLAUDE_API_INTEGRATION.md b/CLAUDE_API_INTEGRATION.md index 72fefc8..1ccaf3d 100644 --- a/CLAUDE_API_INTEGRATION.md +++ b/CLAUDE_API_INTEGRATION.md @@ -1,249 +1,295 @@ -# Claude Integration with Nomad MCP - -This document explains how to configure Claude to connect to the Nomad MCP service and manage jobs. - -## Overview - -The Nomad MCP service provides a simplified REST API specifically designed for Claude to interact with Nomad jobs. This API allows Claude to: - -1. List all jobs in a namespace -2. Get the status of a specific job -3. Start, stop, and restart jobs -4. Create new jobs with a simplified specification -5. Retrieve logs from jobs - -## API Endpoints - -The Claude-specific API is available at the `/api/claude` prefix. The following endpoints are available: - -### List Jobs - -``` -GET /api/claude/list-jobs?namespace=development -``` - -Returns a list of all jobs in the specified namespace with their IDs, names, statuses, and types. - -### Manage Jobs - -``` -POST /api/claude/jobs -``` - -Manages existing jobs with operations like status check, stop, and restart. - -Request body: -```json -{ - "job_id": "example-job", - "action": "status|stop|restart", - "namespace": "development", - "purge": false -} -``` - -### Create Jobs - -``` -POST /api/claude/create-job -``` - -Creates a new job with a simplified specification. - -Request body: -```json -{ - "job_id": "example-job", - "name": "Example Job", - "type": "service", - "datacenters": ["jm"], - "namespace": "development", - "docker_image": "nginx:latest", - "count": 1, - "cpu": 100, - "memory": 128, - "ports": [ - { - "Label": "http", - "Value": 0, - "To": 80 - } - ], - "env_vars": { - "ENV_VAR1": "value1", - "ENV_VAR2": "value2" - } -} -``` - -### Get Job Logs - -``` -GET /api/claude/job-logs/{job_id}?namespace=development -``` - -Retrieves logs from the latest allocation of the specified job. - -## Configuring Claude Desktop Application - -To configure Claude to connect to the Nomad MCP service, follow these steps: - -### 1. Set Up API Access - -Claude needs to be configured with the base URL of your Nomad MCP service. This is typically: - -``` -http://your-server-address:8000 -``` - -### 2. Create a Claude Tool Configuration - -In the Claude desktop application, you can create a custom tool configuration that allows Claude to interact with the Nomad MCP API. Here's a sample configuration: - -```json -{ - "tools": [ - { - "name": "nomad_mcp", - "description": "Manage Nomad jobs through the MCP service", - "api_endpoints": [ - { - "name": "list_jobs", - "description": "List all jobs in a namespace", - "method": "GET", - "url": "http://your-server-address:8000/api/claude/list-jobs", - "params": [ - { - "name": "namespace", - "type": "string", - "description": "Nomad namespace", - "required": false, - "default": "development" - } - ] - }, - { - "name": "manage_job", - "description": "Manage a job (status, stop, restart)", - "method": "POST", - "url": "http://your-server-address:8000/api/claude/jobs", - "body": { - "job_id": "string", - "action": "string", - "namespace": "string", - "purge": "boolean" - } - }, - { - "name": "create_job", - "description": "Create a new job", - "method": "POST", - "url": "http://your-server-address:8000/api/claude/create-job", - "body": { - "job_id": "string", - "name": "string", - "type": "string", - "datacenters": "array", - "namespace": "string", - "docker_image": "string", - "count": "integer", - "cpu": "integer", - "memory": "integer", - "ports": "array", - "env_vars": "object" - } - }, - { - "name": "get_job_logs", - "description": "Get logs for a job", - "method": "GET", - "url": "http://your-server-address:8000/api/claude/job-logs/{job_id}", - "params": [ - { - "name": "namespace", - "type": "string", - "description": "Nomad namespace", - "required": false, - "default": "development" - } - ] - } - ] - } - ] -} -``` - -### 3. Import the Tool Configuration - -1. Open the Claude desktop application -2. Go to Settings > Tools -3. Click "Import Tool Configuration" -4. Select the JSON file with the above configuration -5. Click "Save" - -### 4. Test the Connection - -You can test the connection by asking Claude to list all jobs: - -``` -Please list all jobs in the development namespace using the Nomad MCP service. -``` - -Claude should use the configured tool to make an API request to the Nomad MCP service and return the list of jobs. - -## Example Prompts for Claude - -Here are some example prompts you can use with Claude to interact with the Nomad MCP service: - -### List Jobs - -``` -Please list all jobs in the development namespace. -``` - -### Check Job Status - -``` -What is the status of the job "example-job"? -``` - -### Start a New Job - -``` -Please create a new job with the following specifications: -- Job ID: test-nginx -- Docker image: nginx:latest -- Memory: 256MB -- CPU: 200MHz -- Port mapping: HTTP port 80 -``` - -### Stop a Job - -``` -Please stop the job "test-nginx" and purge it from Nomad. -``` - -### Get Job Logs - -``` -Show me the logs for the job "example-job". -``` - -## Troubleshooting - -If Claude is unable to connect to the Nomad MCP service, check the following: - -1. Ensure the Nomad MCP service is running and accessible from Claude's network -2. Verify the base URL in the tool configuration is correct -3. Check that the Nomad MCP service has proper connectivity to the Nomad server -4. Review the logs of the Nomad MCP service for any errors - -## Security Considerations - -The Claude API integration does not include authentication by default. If you need to secure the API: - -1. Add an API key requirement to the FastAPI application -2. Include the API key in the Claude tool configuration +# Claude Integration with Nomad MCP + +This document explains how to configure Claude to connect to the Nomad MCP service and manage jobs. + +## Overview + +The Nomad MCP service provides a simplified REST API specifically designed for Claude to interact with Nomad jobs. This API allows Claude to: + +1. List all jobs in a namespace +2. Get the status of a specific job +3. Start, stop, and restart jobs +4. Create new jobs with a simplified specification +5. Retrieve logs from jobs + +## API Endpoints + +The Claude-specific API is available at the `/api/claude` prefix. The following endpoints are available: + +### List Jobs + +``` +GET /api/claude/list-jobs?namespace=development +``` + +Returns a list of all jobs in the specified namespace with their IDs, names, statuses, and types. + +### Manage Jobs + +``` +POST /api/claude/jobs +``` + +Manages existing jobs with operations like status check, stop, and restart. + +Request body: +```json +{ + "job_id": "example-job", + "action": "status|stop|restart", + "namespace": "development", + "purge": false +} +``` + +### Create Jobs + +``` +POST /api/claude/create-job +``` + +Creates a new job with a simplified specification. + +Request body: +```json +{ + "job_id": "example-job", + "name": "Example Job", + "type": "service", + "datacenters": ["jm"], + "namespace": "development", + "docker_image": "nginx:latest", + "count": 1, + "cpu": 100, + "memory": 128, + "ports": [ + { + "Label": "http", + "Value": 0, + "To": 80 + } + ], + "env_vars": { + "ENV_VAR1": "value1", + "ENV_VAR2": "value2" + } +} +``` + +### Get Job Logs + +``` +GET /api/claude/job-logs/{job_id}?namespace=development +``` + +Retrieves logs from the latest allocation of the specified job. + +## Claude Integration Options + +There are three main ways to integrate Claude with the Nomad MCP service: + +### Option 1: Using Claude Desktop/Web UI Tool Configuration + +Use this approach for Claude Web or Desktop applications with the tool configuration feature. + +#### 1. Set Up API Access + +Configure the base URL of your Nomad MCP service: +``` +http://your-server-address:8000 +``` + +#### 2. Create a Claude Tool Configuration + +Create a tool configuration file (see sample in `claude_nomad_tool.json`): + +```json +{ + "tools": [ + { + "name": "nomad_mcp", + "description": "Manage Nomad jobs through the MCP service", + "api_endpoints": [ + { + "name": "list_jobs", + "description": "List all jobs in a namespace", + "method": "GET", + "url": "http://your-server-address:8000/api/claude/list-jobs", + "params": [ + { + "name": "namespace", + "type": "string", + "description": "Nomad namespace", + "required": false, + "default": "development" + } + ] + }, + // Other endpoints... + ] + } + ] +} +``` + +#### 3. Import the Tool Configuration + +1. Open the Claude web or desktop application +2. Go to Settings > Tools +3. Click "Import Tool Configuration" +4. Select the JSON file with the configuration +5. Click "Save" + +### Option 2: Using Claude Code CLI with OpenAPI Tool + +For Claude Code CLI integration with the OpenAPI tool approach: + +#### 1. Install Claude Code CLI + +```bash +npm install -g @anthropic-ai/claude-code +``` + +#### 2. Create an OpenAPI Specification File + +Use the updated `claude_nomad_tool.json` file which follows the OpenAPI specification format: + +```json +{ + "schema_version": "v1", + "name": "nomad_mcp", + "description": "Manage Nomad jobs through the MCP service", + "authentication": { + "type": "none" + }, + "api": { + "type": "openapi", + "url": "", + "endpoints": [ + { + "path": "/api/claude/list-jobs", + "method": "GET", + "description": "List all jobs in a namespace", + "parameters": [ + { + "name": "namespace", + "in": "query", + "description": "Nomad namespace", + "required": false, + "schema": { + "type": "string", + "default": "development" + } + } + ] + }, + // Other endpoints... + ] + } +} +``` + +#### 3. Register the Tool with Claude Code + +```bash +claude-code tools register --specification=/path/to/claude_nomad_tool.json --base-url=http://your-server-address:8000 +``` + +### Option 3: Using Claude Code CLI with SSE (Server-Sent Events) + +For a more interactive experience, you can use Claude Code's MCP (Model Context Protocol) with SSE transport: + +#### 1. Install Claude Code CLI + +```bash +npm install -g @anthropic-ai/claude-code +``` + +#### 2. Start Your Nomad MCP Service + +Make sure your Nomad MCP service is running and accessible. + +#### 3. Add the MCP Configuration to Claude Code + +Use the `claude_code_mcp.json` configuration file with the Claude Code CLI: + +```bash +claude-code mcp add nomad_mcp /path/to/claude_code_mcp.json +``` + +This configuration uses the SSE endpoint at `/api/claude/mcp/stream` to create a streaming connection between Claude Code and your service. + +#### 4. Launch Claude Code with the MCP Provider + +```bash +claude-code --mcp nomad_mcp +``` + +The SSE integration provides a more responsive experience with streaming updates and better error handling compared to the regular tool integration. + +## Test the Connection + +You can test the connection by asking Claude to list all jobs: + +``` +Please list all jobs in the development namespace using the Nomad MCP service. +``` + +Claude should use the configured tool to make an API request to the Nomad MCP service and return the list of jobs. + +## Example Prompts for Claude + +Here are some example prompts you can use with Claude to interact with the Nomad MCP service: + +### List Jobs + +``` +Please list all jobs in the development namespace. +``` + +### Check Job Status + +``` +What is the status of the job "example-job"? +``` + +### Start a New Job + +``` +Please create a new job with the following specifications: +- Job ID: test-nginx +- Docker image: nginx:latest +- Memory: 256MB +- CPU: 200MHz +- Port mapping: HTTP port 80 +``` + +### Stop a Job + +``` +Please stop the job "test-nginx" and purge it from Nomad. +``` + +### Get Job Logs + +``` +Show me the logs for the job "example-job". +``` + +## Troubleshooting + +If Claude is unable to connect to the Nomad MCP service, check the following: + +1. Ensure the Nomad MCP service is running and accessible from Claude's network +2. Verify the base URL in the tool configuration is correct +3. Check that the Nomad MCP service has proper connectivity to the Nomad server +4. Review the logs of the Nomad MCP service for any errors +5. Make sure CORS is enabled in your FastAPI application (already configured in this application) + +## Security Considerations + +The Claude API integration does not include authentication by default. If you need to secure the API: + +1. Add an API key requirement to the FastAPI application +2. Include the API key in the Claude tool configuration 3. Consider using HTTPS for all communications between Claude and the Nomad MCP service \ No newline at end of file diff --git a/app/routers/claude.py b/app/routers/claude.py index 1a03b73..20ed0f2 100644 --- a/app/routers/claude.py +++ b/app/routers/claude.py @@ -1,230 +1,460 @@ -from fastapi import APIRouter, HTTPException, Body, Query, Depends -from typing import Dict, Any, List, Optional -import logging -import json - -from app.services.nomad_client import NomadService -from app.schemas.claude_api import ClaudeJobRequest, ClaudeJobSpecification, ClaudeJobResponse - -router = APIRouter() -logger = logging.getLogger(__name__) - -@router.post("/jobs", response_model=ClaudeJobResponse) -async def manage_job(request: ClaudeJobRequest): - """ - Endpoint for Claude to manage Nomad jobs with a simplified interface. - - This endpoint handles job operations like start, stop, restart, and status checks. - """ - try: - # Create a Nomad service instance with the specified namespace - nomad_service = NomadService() - if request.namespace: - nomad_service.namespace = request.namespace - - # Handle different actions - if request.action.lower() == "status": - # Get job status - job = nomad_service.get_job(request.job_id) - - # Get allocations for more detailed status - allocations = nomad_service.get_allocations(request.job_id) - latest_alloc = None - if allocations: - # Sort allocations by creation time (descending) - sorted_allocations = sorted( - allocations, - key=lambda a: a.get("CreateTime", 0), - reverse=True - ) - latest_alloc = sorted_allocations[0] - - return ClaudeJobResponse( - success=True, - job_id=request.job_id, - status=job.get("Status", "unknown"), - message=f"Job {request.job_id} is {job.get('Status', 'unknown')}", - details={ - "job": job, - "latest_allocation": latest_alloc - } - ) - - elif request.action.lower() == "stop": - # Stop the job - result = nomad_service.stop_job(request.job_id, purge=request.purge) - - return ClaudeJobResponse( - success=True, - job_id=request.job_id, - status="stopped", - message=f"Job {request.job_id} has been stopped" + (" and purged" if request.purge else ""), - details=result - ) - - elif request.action.lower() == "restart": - # Get the current job specification - job_spec = nomad_service.get_job(request.job_id) - - # Stop the job - nomad_service.stop_job(request.job_id) - - # Start the job with the original specification - result = nomad_service.start_job(job_spec) - - return ClaudeJobResponse( - success=True, - job_id=request.job_id, - status="restarted", - message=f"Job {request.job_id} has been restarted", - details=result - ) - - else: - # Unknown action - raise HTTPException(status_code=400, detail=f"Unknown action: {request.action}") - - except Exception as e: - logger.error(f"Error managing job {request.job_id}: {str(e)}") - return ClaudeJobResponse( - success=False, - job_id=request.job_id, - status="error", - message=f"Error: {str(e)}", - details=None - ) - -@router.post("/create-job", response_model=ClaudeJobResponse) -async def create_job(job_spec: ClaudeJobSpecification): - """ - Endpoint for Claude to create a new Nomad job with a simplified interface. - - This endpoint allows creating a job with minimal configuration. - """ - try: - # Create a Nomad service instance with the specified namespace - nomad_service = NomadService() - if job_spec.namespace: - nomad_service.namespace = job_spec.namespace - - # Convert the simplified job spec to Nomad format - nomad_job_spec = job_spec.to_nomad_job_spec() - - # Start the job - result = nomad_service.start_job(nomad_job_spec) - - return ClaudeJobResponse( - success=True, - job_id=job_spec.job_id, - status="started", - message=f"Job {job_spec.job_id} has been created and started", - details=result - ) - - except Exception as e: - logger.error(f"Error creating job {job_spec.job_id}: {str(e)}") - return ClaudeJobResponse( - success=False, - job_id=job_spec.job_id, - status="error", - message=f"Error: {str(e)}", - details=None - ) - -@router.get("/list-jobs", response_model=List[Dict[str, Any]]) -async def list_jobs(namespace: str = Query("development")): - """ - List all jobs in the specified namespace. - - Returns a simplified list of jobs with their IDs and statuses. - """ - try: - # Create a Nomad service instance with the specified namespace - nomad_service = NomadService() - nomad_service.namespace = namespace - - # Get all jobs - jobs = nomad_service.list_jobs() - - # Return a simplified list - simplified_jobs = [] - for job in jobs: - simplified_jobs.append({ - "id": job.get("ID"), - "name": job.get("Name"), - "status": job.get("Status"), - "type": job.get("Type"), - "namespace": namespace - }) - - return simplified_jobs - - except Exception as e: - logger.error(f"Error listing jobs: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error listing jobs: {str(e)}") - -@router.get("/job-logs/{job_id}", response_model=Dict[str, Any]) -async def get_job_logs(job_id: str, namespace: str = Query("development")): - """ - Get logs for a job. - - Returns logs from the latest allocation of the job. - """ - try: - # Create a Nomad service instance with the specified namespace - nomad_service = NomadService() - nomad_service.namespace = namespace - - # Get allocations for the job - allocations = nomad_service.get_allocations(job_id) - if not allocations: - return { - "success": False, - "job_id": job_id, - "message": f"No allocations found for job {job_id}", - "logs": None - } - - # Sort allocations by creation time (descending) - sorted_allocations = sorted( - allocations, - key=lambda a: a.get("CreateTime", 0), - reverse=True - ) - latest_alloc = sorted_allocations[0] - alloc_id = latest_alloc.get("ID") - - # Get the task name from the allocation - task_name = None - if "TaskStates" in latest_alloc: - task_states = latest_alloc["TaskStates"] - if task_states: - task_name = next(iter(task_states.keys())) - - if not task_name: - task_name = "app" # Default task name - - # Get logs for the allocation - stdout_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stdout") - stderr_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stderr") - - return { - "success": True, - "job_id": job_id, - "allocation_id": alloc_id, - "task_name": task_name, - "message": f"Retrieved logs for job {job_id}", - "logs": { - "stdout": stdout_logs, - "stderr": stderr_logs - } - } - - except Exception as e: - logger.error(f"Error getting logs for job {job_id}: {str(e)}") - return { - "success": False, - "job_id": job_id, - "message": f"Error getting logs: {str(e)}", - "logs": None - } \ No newline at end of file +from fastapi import APIRouter, HTTPException, Body, Query, Depends, Request +from fastapi.responses import StreamingResponse +from typing import Dict, Any, List, Optional, AsyncGenerator +import logging +import json +import asyncio + +from app.services.nomad_client import NomadService +from app.schemas.claude_api import ClaudeJobRequest, ClaudeJobSpecification, ClaudeJobResponse + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.post("/jobs", response_model=ClaudeJobResponse) +async def manage_job(request: ClaudeJobRequest): + """ + Endpoint for Claude to manage Nomad jobs with a simplified interface. + + This endpoint handles job operations like start, stop, restart, and status checks. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + if request.namespace: + nomad_service.namespace = request.namespace + + # Handle different actions + if request.action.lower() == "status": + # Get job status + job = nomad_service.get_job(request.job_id) + + # Get allocations for more detailed status + allocations = nomad_service.get_allocations(request.job_id) + latest_alloc = None + if allocations: + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + latest_alloc = sorted_allocations[0] + + return ClaudeJobResponse( + success=True, + job_id=request.job_id, + status=job.get("Status", "unknown"), + message=f"Job {request.job_id} is {job.get('Status', 'unknown')}", + details={ + "job": job, + "latest_allocation": latest_alloc + } + ) + + elif request.action.lower() == "stop": + # Stop the job + result = nomad_service.stop_job(request.job_id, purge=request.purge) + + return ClaudeJobResponse( + success=True, + job_id=request.job_id, + status="stopped", + message=f"Job {request.job_id} has been stopped" + (" and purged" if request.purge else ""), + details=result + ) + + elif request.action.lower() == "restart": + # Get the current job specification + job_spec = nomad_service.get_job(request.job_id) + + # Stop the job + nomad_service.stop_job(request.job_id) + + # Start the job with the original specification + result = nomad_service.start_job(job_spec) + + return ClaudeJobResponse( + success=True, + job_id=request.job_id, + status="restarted", + message=f"Job {request.job_id} has been restarted", + details=result + ) + + else: + # Unknown action + raise HTTPException(status_code=400, detail=f"Unknown action: {request.action}") + + except Exception as e: + logger.error(f"Error managing job {request.job_id}: {str(e)}") + return ClaudeJobResponse( + success=False, + job_id=request.job_id, + status="error", + message=f"Error: {str(e)}", + details=None + ) + +@router.post("/create-job", response_model=ClaudeJobResponse) +async def create_job(job_spec: ClaudeJobSpecification): + """ + Endpoint for Claude to create a new Nomad job with a simplified interface. + + This endpoint allows creating a job with minimal configuration. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + if job_spec.namespace: + nomad_service.namespace = job_spec.namespace + + # Convert the simplified job spec to Nomad format + nomad_job_spec = job_spec.to_nomad_job_spec() + + # Start the job + result = nomad_service.start_job(nomad_job_spec) + + return ClaudeJobResponse( + success=True, + job_id=job_spec.job_id, + status="started", + message=f"Job {job_spec.job_id} has been created and started", + details=result + ) + + except Exception as e: + logger.error(f"Error creating job {job_spec.job_id}: {str(e)}") + return ClaudeJobResponse( + success=False, + job_id=job_spec.job_id, + status="error", + message=f"Error: {str(e)}", + details=None + ) + +@router.get("/list-jobs", response_model=List[Dict[str, Any]]) +async def list_jobs(namespace: str = Query("development")): + """ + List all jobs in the specified namespace. + + Returns a simplified list of jobs with their IDs and statuses. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + nomad_service.namespace = namespace + + # Get all jobs + jobs = nomad_service.list_jobs() + + # Return a simplified list + simplified_jobs = [] + for job in jobs: + simplified_jobs.append({ + "id": job.get("ID"), + "name": job.get("Name"), + "status": job.get("Status"), + "type": job.get("Type"), + "namespace": namespace + }) + + return simplified_jobs + + except Exception as e: + logger.error(f"Error listing jobs: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error listing jobs: {str(e)}") + +@router.get("/job-logs/{job_id}", response_model=Dict[str, Any]) +async def get_job_logs(job_id: str, namespace: str = Query("development")): + """ + Get logs for a job. + + Returns logs from the latest allocation of the job. + """ + try: + # Create a Nomad service instance with the specified namespace + nomad_service = NomadService() + nomad_service.namespace = namespace + + # Get allocations for the job + allocations = nomad_service.get_allocations(job_id) + if not allocations: + return { + "success": False, + "job_id": job_id, + "message": f"No allocations found for job {job_id}", + "logs": None + } + + # Sort allocations by creation time (descending) + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + latest_alloc = sorted_allocations[0] + alloc_id = latest_alloc.get("ID") + + # Get the task name from the allocation + task_name = None + if "TaskStates" in latest_alloc: + task_states = latest_alloc["TaskStates"] + if task_states: + task_name = next(iter(task_states.keys())) + + if not task_name: + task_name = "app" # Default task name + + # Get logs for the allocation + stdout_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stdout") + stderr_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stderr") + + return { + "success": True, + "job_id": job_id, + "allocation_id": alloc_id, + "task_name": task_name, + "message": f"Retrieved logs for job {job_id}", + "logs": { + "stdout": stdout_logs, + "stderr": stderr_logs + } + } + + except Exception as e: + logger.error(f"Error getting logs for job {job_id}: {str(e)}") + return { + "success": False, + "job_id": job_id, + "message": f"Error getting logs: {str(e)}", + "logs": None + } + +@router.post("/mcp/stream") +async def mcp_stream(request: Request): + """ + MCP streaming endpoint for Claude Code using Server-Sent Events (SSE). + + This endpoint allows Claude Code to connect using the SSE transport mode. + It receives requests from Claude Code and streams responses back. + """ + async def event_generator() -> AsyncGenerator[str, None]: + # Read request data + request_data = await request.json() + logger.info(f"Received MCP request: {request_data}") + + # Extract the request type and content + request_type = request_data.get("type", "unknown") + request_content = request_data.get("content", {}) + request_id = request_data.get("id", "unknown") + + # Send initial acknowledgment + yield f"data: {json.dumps({'id': request_id, 'type': 'ack', 'content': 'Processing request'})}\n\n" + + try: + # Process different request types + if request_type == "nomad_list_jobs": + # Handle list jobs request + namespace = request_content.get("namespace", "development") + + # Create Nomad service and get jobs + nomad_service = NomadService() + nomad_service.namespace = namespace + jobs = nomad_service.list_jobs() + + # Format job information + simplified_jobs = [] + for job in jobs: + simplified_jobs.append({ + "id": job.get("ID"), + "name": job.get("Name"), + "status": job.get("Status"), + "type": job.get("Type"), + "namespace": namespace + }) + + # Send result + yield f"data: {json.dumps({'id': request_id, 'type': 'result', 'content': simplified_jobs})}\n\n" + + elif request_type == "nomad_job_status": + # Handle job status request + job_id = request_content.get("job_id") + namespace = request_content.get("namespace", "development") + + if not job_id: + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': 'Job ID is required'})}\n\n" + else: + # Create Nomad service and get job status + nomad_service = NomadService() + nomad_service.namespace = namespace + job = nomad_service.get_job(job_id) + + # Get latest allocation + allocations = nomad_service.get_allocations(job_id) + latest_alloc = None + if allocations: + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + latest_alloc = sorted_allocations[0] + + # Send result + yield f"data: {json.dumps({'id': request_id, 'type': 'result', 'content': { + 'job_id': job_id, + 'status': job.get('Status', 'unknown'), + 'message': f'Job {job_id} is {job.get('Status', 'unknown')}', + 'details': { + 'job': job, + 'latest_allocation': latest_alloc + } + }})}\n\n" + + elif request_type == "nomad_job_action": + # Handle job action request (stop, restart) + job_id = request_content.get("job_id") + action = request_content.get("action") + namespace = request_content.get("namespace", "development") + purge = request_content.get("purge", False) + + if not job_id or not action: + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': 'Job ID and action are required'})}\n\n" + else: + # Create Nomad service + nomad_service = NomadService() + nomad_service.namespace = namespace + + if action.lower() == "stop": + # Stop the job + result = nomad_service.stop_job(job_id, purge=purge) + yield f"data: {json.dumps({'id': request_id, 'type': 'result', 'content': { + 'job_id': job_id, + 'status': 'stopped', + 'message': f'Job {job_id} has been stopped' + (' and purged' if purge else ''), + 'details': result + }})}\n\n" + + elif action.lower() == "restart": + # Restart the job + job_spec = nomad_service.get_job(job_id) + nomad_service.stop_job(job_id) + result = nomad_service.start_job(job_spec) + + yield f"data: {json.dumps({'id': request_id, 'type': 'result', 'content': { + 'job_id': job_id, + 'status': 'restarted', + 'message': f'Job {job_id} has been restarted', + 'details': result + }})}\n\n" + else: + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': f'Unknown action: {action}'})}\n\n" + + elif request_type == "nomad_create_job": + # Handle create job request + try: + # Convert request content to job spec + job_spec = ClaudeJobSpecification(**request_content) + + # Create Nomad service + nomad_service = NomadService() + if job_spec.namespace: + nomad_service.namespace = job_spec.namespace + + # Convert to Nomad format and start job + nomad_job_spec = job_spec.to_nomad_job_spec() + result = nomad_service.start_job(nomad_job_spec) + + yield f"data: {json.dumps({'id': request_id, 'type': 'result', 'content': { + 'job_id': job_spec.job_id, + 'status': 'started', + 'message': f'Job {job_spec.job_id} has been created and started', + 'details': result + }})}\n\n" + except Exception as e: + logger.error(f"Error creating job: {str(e)}") + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': f'Error creating job: {str(e)}'})}\n\n" + + elif request_type == "nomad_job_logs": + # Handle job logs request + job_id = request_content.get("job_id") + namespace = request_content.get("namespace", "development") + + if not job_id: + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': 'Job ID is required'})}\n\n" + else: + try: + # Create Nomad service + nomad_service = NomadService() + nomad_service.namespace = namespace + + # Get job allocations + allocations = nomad_service.get_allocations(job_id) + if not allocations: + yield f"data: {json.dumps({'id': request_id, 'type': 'result', 'content': { + 'success': False, + 'job_id': job_id, + 'message': f'No allocations found for job {job_id}', + 'logs': None + }})}\n\n" + else: + # Get latest allocation and logs + sorted_allocations = sorted( + allocations, + key=lambda a: a.get("CreateTime", 0), + reverse=True + ) + latest_alloc = sorted_allocations[0] + alloc_id = latest_alloc.get("ID") + + # Get task name + task_name = None + if "TaskStates" in latest_alloc: + task_states = latest_alloc["TaskStates"] + if task_states: + task_name = next(iter(task_states.keys())) + + if not task_name: + task_name = "app" # Default task name + + # Get logs + stdout_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stdout") + stderr_logs = nomad_service.get_allocation_logs(alloc_id, task_name, "stderr") + + yield f"data: {json.dumps({'id': request_id, 'type': 'result', 'content': { + 'success': True, + 'job_id': job_id, + 'allocation_id': alloc_id, + 'task_name': task_name, + 'message': f'Retrieved logs for job {job_id}', + 'logs': { + 'stdout': stdout_logs, + 'stderr': stderr_logs + } + }})}\n\n" + except Exception as e: + logger.error(f"Error getting logs for job {job_id}: {str(e)}") + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': f'Error getting logs: {str(e)}'})}\n\n" + + else: + # Unknown request type + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': f'Unknown request type: {request_type}'})}\n\n" + + # Send completion message + yield f"data: {json.dumps({'id': request_id, 'type': 'done'})}\n\n" + + except Exception as e: + logger.error(f"Error processing MCP request: {str(e)}") + yield f"data: {json.dumps({'id': request_id, 'type': 'error', 'content': f'Internal server error: {str(e)}'})}\n\n" + yield f"data: {json.dumps({'id': request_id, 'type': 'done'})}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Needed for Nginx + } + ) \ No newline at end of file diff --git a/claude_code_mcp.json b/claude_code_mcp.json new file mode 100644 index 0000000..fa43c32 --- /dev/null +++ b/claude_code_mcp.json @@ -0,0 +1,249 @@ +{ + "name": "nomad_mcp_sse", + "version": "1.0.0", + "description": "Nomad MCP service for Claude Code using SSE", + "transport": { + "type": "sse", + "url": "http://localhost:8000/api/claude/mcp/stream" + }, + "authentication": { + "type": "none" + }, + "capabilities": [ + { + "type": "function", + "function": { + "name": "list_nomad_jobs", + "description": "List all Nomad jobs in a namespace", + "parameters": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Nomad namespace", + "default": "development" + } + } + }, + "required": [] + }, + "implementation": { + "type": "nomad_list_jobs", + "content": { + "namespace": "${parameters.namespace}" + } + } + }, + { + "type": "function", + "function": { + "name": "get_job_status", + "description": "Get the status of a specific Nomad job", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "ID of the job to check" + }, + "namespace": { + "type": "string", + "description": "Nomad namespace", + "default": "development" + } + } + }, + "required": ["job_id"] + }, + "implementation": { + "type": "nomad_job_status", + "content": { + "job_id": "${parameters.job_id}", + "namespace": "${parameters.namespace}" + } + } + }, + { + "type": "function", + "function": { + "name": "stop_job", + "description": "Stop a running Nomad job", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "ID of the job to stop" + }, + "namespace": { + "type": "string", + "description": "Nomad namespace", + "default": "development" + }, + "purge": { + "type": "boolean", + "description": "Whether to purge the job", + "default": false + } + } + }, + "required": ["job_id"] + }, + "implementation": { + "type": "nomad_job_action", + "content": { + "job_id": "${parameters.job_id}", + "action": "stop", + "namespace": "${parameters.namespace}", + "purge": "${parameters.purge}" + } + } + }, + { + "type": "function", + "function": { + "name": "restart_job", + "description": "Restart a Nomad job", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "ID of the job to restart" + }, + "namespace": { + "type": "string", + "description": "Nomad namespace", + "default": "development" + } + } + }, + "required": ["job_id"] + }, + "implementation": { + "type": "nomad_job_action", + "content": { + "job_id": "${parameters.job_id}", + "action": "restart", + "namespace": "${parameters.namespace}" + } + } + }, + { + "type": "function", + "function": { + "name": "create_job", + "description": "Create a new Nomad job", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Unique ID for the job" + }, + "name": { + "type": "string", + "description": "Display name for the job" + }, + "type": { + "type": "string", + "description": "Job type (service, batch, etc.)", + "default": "service" + }, + "datacenters": { + "type": "array", + "description": "List of datacenters to run the job in", + "items": { + "type": "string" + }, + "default": ["jm"] + }, + "namespace": { + "type": "string", + "description": "Nomad namespace", + "default": "development" + }, + "docker_image": { + "type": "string", + "description": "Docker image to run" + }, + "count": { + "type": "integer", + "description": "Number of instances to run", + "default": 1 + }, + "cpu": { + "type": "integer", + "description": "CPU allocation in MHz", + "default": 100 + }, + "memory": { + "type": "integer", + "description": "Memory allocation in MB", + "default": 128 + }, + "ports": { + "type": "array", + "description": "Port mappings", + "items": { + "type": "object" + }, + "default": [] + }, + "env_vars": { + "type": "object", + "description": "Environment variables for the container", + "default": {} + } + } + }, + "required": ["job_id", "name", "docker_image"] + }, + "implementation": { + "type": "nomad_create_job", + "content": { + "job_id": "${parameters.job_id}", + "name": "${parameters.name}", + "type": "${parameters.type}", + "datacenters": "${parameters.datacenters}", + "namespace": "${parameters.namespace}", + "docker_image": "${parameters.docker_image}", + "count": "${parameters.count}", + "cpu": "${parameters.cpu}", + "memory": "${parameters.memory}", + "ports": "${parameters.ports}", + "env_vars": "${parameters.env_vars}" + } + } + }, + { + "type": "function", + "function": { + "name": "get_job_logs", + "description": "Get logs for a Nomad job", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "ID of the job to get logs for" + }, + "namespace": { + "type": "string", + "description": "Nomad namespace", + "default": "development" + } + } + }, + "required": ["job_id"] + }, + "implementation": { + "type": "nomad_job_logs", + "content": { + "job_id": "${parameters.job_id}", + "namespace": "${parameters.namespace}" + } + } + } + ] +} \ No newline at end of file diff --git a/claude_nomad_tool.json b/claude_nomad_tool.json index b6ac1ee..e1ea6dd 100644 --- a/claude_nomad_tool.json +++ b/claude_nomad_tool.json @@ -1,71 +1,159 @@ -{ - "tools": [ - { - "name": "nomad_mcp", - "description": "Manage Nomad jobs through the MCP service", - "api_endpoints": [ - { - "name": "list_jobs", - "description": "List all jobs in a namespace", - "method": "GET", - "url": "http://127.0.0.1:8000/api/claude/list-jobs", - "params": [ - { - "name": "namespace", - "type": "string", - "description": "Nomad namespace", - "required": false, - "default": "development" - } - ] - }, - { - "name": "manage_job", - "description": "Manage a job (status, stop, restart)", - "method": "POST", - "url": "http://127.0.0.1:8000/api/claude/jobs", - "body": { - "job_id": "string", - "action": "string", - "namespace": "string", - "purge": "boolean" - } - }, - { - "name": "create_job", - "description": "Create a new job", - "method": "POST", - "url": "http://127.0.0.1:8000/api/claude/create-job", - "body": { - "job_id": "string", - "name": "string", - "type": "string", - "datacenters": "array", - "namespace": "string", - "docker_image": "string", - "count": "integer", - "cpu": "integer", - "memory": "integer", - "ports": "array", - "env_vars": "object" - } - }, - { - "name": "get_job_logs", - "description": "Get logs for a job", - "method": "GET", - "url": "http://127.0.0.1:8000/api/claude/job-logs/{job_id}", - "params": [ - { - "name": "namespace", - "type": "string", - "description": "Nomad namespace", - "required": false, - "default": "development" - } - ] - } - ] - } - ] +{ + "schema_version": "v1", + "name": "nomad_mcp", + "description": "Manage Nomad jobs through the MCP service", + "authentication": { + "type": "none" + }, + "api": { + "type": "openapi", + "url": "", + "endpoints": [ + { + "path": "/api/claude/list-jobs", + "method": "GET", + "description": "List all jobs in a namespace", + "parameters": [ + { + "name": "namespace", + "in": "query", + "description": "Nomad namespace", + "required": false, + "schema": { + "type": "string", + "default": "development" + } + } + ] + }, + { + "path": "/api/claude/jobs", + "method": "POST", + "description": "Manage a job (status, stop, restart)", + "request_body": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["job_id", "action"], + "properties": { + "job_id": { + "type": "string", + "description": "ID of the job to manage" + }, + "action": { + "type": "string", + "description": "Action to perform: status, stop, or restart" + }, + "namespace": { + "type": "string", + "description": "Nomad namespace" + }, + "purge": { + "type": "boolean", + "description": "Whether to purge the job when stopping" + } + } + } + } + } + } + }, + { + "path": "/api/claude/create-job", + "method": "POST", + "description": "Create a new job", + "request_body": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["job_id", "name", "type", "docker_image"], + "properties": { + "job_id": { + "type": "string", + "description": "Unique ID for the job" + }, + "name": { + "type": "string", + "description": "Display name for the job" + }, + "type": { + "type": "string", + "description": "Job type (service, batch, etc.)" + }, + "datacenters": { + "type": "array", + "description": "List of datacenters to run the job in", + "items": { + "type": "string" + } + }, + "namespace": { + "type": "string", + "description": "Nomad namespace" + }, + "docker_image": { + "type": "string", + "description": "Docker image to run" + }, + "count": { + "type": "integer", + "description": "Number of instances to run" + }, + "cpu": { + "type": "integer", + "description": "CPU allocation in MHz" + }, + "memory": { + "type": "integer", + "description": "Memory allocation in MB" + }, + "ports": { + "type": "array", + "description": "Port mappings", + "items": { + "type": "object" + } + }, + "env_vars": { + "type": "object", + "description": "Environment variables for the container" + } + } + } + } + } + } + }, + { + "path": "/api/claude/job-logs/{job_id}", + "method": "GET", + "description": "Get logs for a job", + "parameters": [ + { + "name": "job_id", + "in": "path", + "description": "ID of the job to get logs for", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "namespace", + "in": "query", + "description": "Nomad namespace", + "required": false, + "schema": { + "type": "string", + "default": "development" + } + } + ] + } + ] + } } \ No newline at end of file