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

@ -84,21 +84,24 @@ GET /api/claude/job-logs/{job_id}?namespace=development
Retrieves logs from the latest allocation of the specified job.
## Configuring Claude Desktop Application
## Claude Integration Options
To configure Claude to connect to the Nomad MCP service, follow these steps:
There are three main ways to integrate Claude with the Nomad MCP service:
### 1. Set Up API Access
### Option 1: Using Claude Desktop/Web UI Tool Configuration
Claude needs to be configured with the base URL of your Nomad MCP service. This is typically:
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
#### 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:
Create a tool configuration file (see sample in `claude_nomad_tool.json`):
```json
{
@ -122,67 +125,109 @@ In the Claude desktop application, you can create a custom tool configuration th
}
]
},
{
"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"
}
]
}
// Other endpoints...
]
}
]
}
```
### 3. Import the Tool Configuration
#### 3. Import the Tool Configuration
1. Open the Claude desktop application
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 above configuration
4. Select the JSON file with the configuration
5. Click "Save"
### 4. Test the Connection
### 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:
@ -239,6 +284,7 @@ If Claude is unable to connect to the Nomad MCP service, check the following:
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

View File

@ -1,7 +1,9 @@
from fastapi import APIRouter, HTTPException, Body, Query, Depends
from typing import Dict, Any, List, Optional
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
@ -228,3 +230,231 @@ async def get_job_logs(job_id: str, namespace: str = Query("development")):
"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": [
{
"schema_version": "v1",
"name": "nomad_mcp",
"description": "Manage Nomad jobs through the MCP service",
"api_endpoints": [
"authentication": {
"type": "none"
},
"api": {
"type": "openapi",
"url": "",
"endpoints": [
{
"name": "list_jobs",
"path": "/api/claude/list-jobs",
"method": "GET",
"description": "List all jobs in a namespace",
"method": "GET",
"url": "http://127.0.0.1:8000/api/claude/list-jobs",
"params": [
"parameters": [
{
"name": "namespace",
"type": "string",
"in": "query",
"description": "Nomad namespace",
"required": false,
"schema": {
"type": "string",
"default": "development"
}
}
]
},
{
"name": "manage_job",
"path": "/api/claude/jobs",
"method": "POST",
"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",
"request_body": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["job_id", "action"],
"properties": {
"job_id": {
"type": "string",
"datacenters": "array",
"namespace": "string",
"docker_image": "string",
"count": "integer",
"cpu": "integer",
"memory": "integer",
"ports": "array",
"env_vars": "object"
"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"
}
}
}
}
}
}
},
{
"name": "get_job_logs",
"description": "Get logs for a job",
"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",
"url": "http://127.0.0.1:8000/api/claude/job-logs/{job_id}",
"params": [
"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",
"type": "string",
"in": "query",
"description": "Nomad namespace",
"required": false,
"schema": {
"type": "string",
"default": "development"
}
]
}
]
}
]
}
}