Add Claude Code integration with SSE support

- Add Server-Sent Events (SSE) endpoint for Claude Code MCP integration
- Create MCP configuration for Claude Code CLI
- Update tool configuration to support modern OpenAPI format
- Add documentation for Claude Code integration options
- Create CLAUDE.md guide for AI coding agents

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-03-29 12:52:21 +07:00
parent afae299e9c
commit 403fa50b4f
5 changed files with 1188 additions and 548 deletions

27
CLAUDE.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -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
}
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
}
)

249
claude_code_mcp.json Normal file
View File

@ -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}"
}
}
}
]
}

View File

@ -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"
}
}
]
}
]
}
}