diff --git a/claude/Dockerfile b/claude/Dockerfile new file mode 100644 index 0000000..1ad1e23 --- /dev/null +++ b/claude/Dockerfile @@ -0,0 +1,46 @@ +# Build stage for MCP server +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -o claude-mcp-server ./claude/ + +# Final stage - use Debian instead of Alpine for glibc compatibility +FROM ghcr.io/anomalyco/opencode + +# Install required runtime dependencies +RUN apk add git curl bash + +# Install GitHub CLI +RUN echo "@community http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && apk add github-cli@community + +# Copy the MCP server binary +COPY --from=builder /app/claude-mcp-server /usr/local/bin/claude-mcp-server + +# Set environment variables +ENV CLAUDE_SESSION_DIR=/root +ENV CLAUDE_BINARY=/usr/local/bin/claude +ENV CLAUDE_MAX_SESSIONS=10 +ENV CLAUDE_LOG_RETENTION_HOURS=24 +ENV CLAUDE_FORMAT=json +ENV CLAUDE_SHARE=false + +# Optional environment variables (uncomment to use): +# ENV CLAUDE_MODEL=openai/gpt-4 +# ENV CLAUDE_AGENT= +# ENV CLAUDE_VARIANT= +# ENV CLAUDE_ATTACH= +# ENV CLAUDE_PORT=0 +# ENV CLAUDE_ALLOWED_TOOLS= +# ENV CLAUDE_TOOLS= + +# Run the MCP server +ENTRYPOINT ["claude-mcp-server"] diff --git a/claude/README.md b/claude/README.md new file mode 100644 index 0000000..ac2e557 --- /dev/null +++ b/claude/README.md @@ -0,0 +1,192 @@ +# Claude MCP Server + +This is an MCP (Model Context Protocol) server that provides integration with Claude Code CLI, allowing other MCP clients to delegate tasks to Claude Code in the background. It follows the same pattern as the opencode MCP server but is specifically designed for Claude Code. + +## Features + +- **Background Task Delegation**: Start Claude Code sessions in the background and monitor their status +- **Session Management**: Create, monitor, stop, and list sessions +- **Log Access**: Retrieve stdout and stderr logs from running or completed sessions +- **Tool Restrictions**: Support for `--allowedTools` and `--tools` flags to control Claude's capabilities +- **Environment Passthrough**: All OS environment variables are passed to Claude subprocesses +- **Configurable**: Extensive environment variables for customization + +## Usage + +### Adding the Server + +Add this MCP server to your Claude Code configuration: + +```bash +claude mcp add claude-mcp -- npx -y mudler/MCPs/claude +``` + +Or use the Docker-based approach if you've built the Docker image: + +```bash +claude mcp add claude-mcp -- docker run -i --rm claude-mcp-server +``` + +### Starting a Session + +Use the `start_session` tool to begin a new Claude session: + +```json +{ + "message": "Find and fix the bug in auth.py", + "allowed_tools": "Bash,Read,Edit", + "title": "Bug Fix Task" +} +``` + +This returns a session ID that can be used to monitor progress. + +### Checking Session Status + +Use the `get_session_status` tool with the session ID to check if the session is: +- `starting` - Session is initializing +- `running` - Claude is actively working +- `completed` - Task finished successfully (exit code 0) +- `failed` - Task failed with non-zero exit code +- `stopped` - Session was manually stopped +- `not_found` - Session ID doesn't exist + +### Retrieving Logs + +Use the `get_session_logs` tool to see what Claude is doing: +- Specify the session ID +- Optionally specify number of lines to retrieve (default 100) +- Returns both stdout and stderr + +### Stopping a Session + +Use the `stop_session` tool to terminate a running session. Use `force=true` to kill immediately. + +### Listing Sessions + +Use the `list_sessions` tool to see all sessions, optionally filtered by status. + +## Environment Variables + +### Server Configuration + +- `CLAUDE_SESSION_DIR`: Directory for session state (default: `/tmp/claude-sessions`) +- `CLAUDE_MAX_SESSIONS`: Maximum concurrent sessions (default: `10`) +- `CLAUDE_LOG_RETENTION_HOURS`: Hours to keep logs before cleanup (default: `24`) +- `CLAUDE_WORK_DIR`: Working directory for Claude processes (default: `/root`) + +### Claude Code Invocation + +- `CLAUDE_BINARY`: Path to Claude binary (default: `claude`) +- `CLAUDE_MODEL`: Default model to use (e.g., `sonnet`, `opus`) +- `CLAUDE_AGENT`: Specify an agent for sessions +- `CLAUDE_FORMAT`: Output format (default: `json`) +- `CLAUDE_SHARE`: Whether to share sessions (`true`/`false`) +- `CLAUDE_ATTACH`: Files to attach to sessions +- `CLAUDE_PORT`: Port for remote control +- `CLAUDE_VARIANT`: Model variant +- `CLAUDE_ALLOWED_TOOLS`: Tools allowed without prompting (e.g., `"Bash,Read,Edit"`) +- `CLAUDE_TOOLS`: Restrict which tools Claude can use (e.g., `"Bash,Read,Edit"`) + +### Tool Naming (Optional) + +- `CLAUDE_TOOL_START_SESSION_NAME`: Override the start_session tool name +- `CLAUDE_TOOL_GET_SESSION_STATUS_NAME`: Override the get_session_status tool name +- `CLAUDE_TOOL_GET_SESSION_LOGS_NAME`: Override the get_session_logs tool name +- `CLAUDE_TOOL_STOP_SESSION_NAME`: Override the stop_session tool name +- `CLAUDE_TOOL_LIST_SESSIONS_NAME`: Override the list_sessions tool name + +## Integration with MCPs Repository + +This server is designed to be part of the [MCPs](https://github.com/mudler/MCPs) collection. It follows the same structure and patterns as other MCP servers in that repository. + +### Directory Structure + +``` +claude/ +├── main.go # MCP server setup and tool registration +├── session.go # Session management logic +├── handlers.go # Tool handlers (input/output types) +├── Dockerfile # Container image definition +└── README.md # This file +``` + +### Building + +The server can be built using the Dockerfile or directly with Go: + +```bash +go build -o claude-mcp-server ./claude +``` + +### Testing + +You can test the server directly: + +```bash +# Run the server (it will wait for MCP input on stdin/stdout) +./claude-mcp-server +``` + +Or test with the MCP inspector: + +```bash +npx @modelcontextprotocol/inspector ./claude-mcp-server +``` + +## Security Considerations + +- The server runs Claude Code with the permissions of the user running the MCP client +- Environment variables are passed through to Claude subprocesses +- Tool restrictions (`--allowedTools`, `--tools`) should be used to limit Claude's capabilities +- Session logs are stored in the session directory and cleaned up based on retention policy +- Consider using `CLAUDE_MAX_SESSIONS` to limit resource usage + +## Example Workflow + +1. **Start a session** to fix a bug: + ``` + start_session(message="Fix the authentication bug in login.go", allowed_tools="Bash,Read,Edit") + ``` + +2. **Check status** after a few seconds: + ``` + get_session_status(session_id="abc123...") + ``` + +3. **Get logs** to see progress: + ``` + get_session_logs(session_id="abc123...", lines=50) + ``` + +4. **Stop if needed** (if it's taking too long or going wrong): + ``` + stop_session(session_id="abc123...", force=false) + ``` + +5. **List all sessions** to overview: + ``` + list_sessions(status_filter="running") + ``` + +## Troubleshooting + +### Sessions fail to start +- Check that `CLAUDE_BINARY` points to a valid Claude executable +- Verify the Claude binary is in PATH or specify full path +- Check session logs for error messages +- Ensure environment variables are properly set + +### No tools available +- Check `CLAUDE_ALLOWED_TOOLS` and `CLAUDE_TOOLS` environment variables +- Remember that `--allowedTools` enables tools without prompting, while `--tools` restricts which tools are available +- Use `claude --help` to see available tool names + +### High resource usage +- Reduce `CLAUDE_MAX_SESSIONS` to limit concurrent sessions +- Set `CLAUDE_LOG_RETENTION_HOURS` to a lower value for faster cleanup +- Use `stop_session` to terminate unused sessions + +## License + +This MCP server is part of the MCPs repository and follows the same license (MIT). diff --git a/claude/claude-mcp-server b/claude/claude-mcp-server new file mode 100755 index 0000000..9a07fae Binary files /dev/null and b/claude/claude-mcp-server differ diff --git a/claude/go.mod b/claude/go.mod new file mode 100644 index 0000000..3f2d952 --- /dev/null +++ b/claude/go.mod @@ -0,0 +1,18 @@ +module github.com/mudler/MCPs/claude + +go 1.25.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/modelcontextprotocol/go-sdk v1.4.0 + github.com/mudler/go-processmanager v0.1.0 +) + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/claude/go.sum b/claude/go.sum new file mode 100644 index 0000000..3e7df97 --- /dev/null +++ b/claude/go.sum @@ -0,0 +1,40 @@ +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= +github.com/mudler/go-processmanager v0.1.0 h1:fcSKgF9U/a1Z7KofAFeZnke5YseadCI5GqL9oT0LS3E= +github.com/mudler/go-processmanager v0.1.0/go.mod h1:h6kmHUZeafr+k5hRYpGLMzJFH4hItHffgpRo2QIkP+o= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/claude/handlers.go b/claude/handlers.go new file mode 100644 index 0000000..dfa039d --- /dev/null +++ b/claude/handlers.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// StartSessionInput represents the input for starting a session +type StartSessionInput struct { + Message string `json:"message" jsonschema:"the message to send to Claude"` + Files []string `json:"files,omitempty" jsonschema:"file(s) to attach to message"` + Title string `json:"title,omitempty" jsonschema:"title for the session"` + Continue bool `json:"continue,omitempty" jsonschema:"continue the last session"` + SessionID string `json:"session_id,omitempty" jsonschema:"session id to continue"` + Thinking bool `json:"thinking,omitempty" jsonschema:"show thinking blocks"` +} + +// StartSessionOutput represents the output from starting a session +type StartSessionOutput struct { + SessionID string `json:"session_id" jsonschema:"the unique session ID"` + Status string `json:"status" jsonschema:"the session status (starting)"` + PID string `json:"pid,omitempty" jsonschema:"the process ID if available"` + Message string `json:"message" jsonschema:"status message"` +} + +// StartSessionHandler handles starting a new Claude session +func StartSessionHandler(ctx context.Context, req *mcp.CallToolRequest, input StartSessionInput) (*mcp.CallToolResult, StartSessionOutput, error) { + if globalSessionManager == nil { + return nil, StartSessionOutput{}, fmt.Errorf("session manager not initialized") + } + + session, err := globalSessionManager.CreateSession( + input.Message, + input.Title, + input.SessionID, + input.Files, + input.Continue, + input.Thinking, + ) + if err != nil { + return nil, StartSessionOutput{}, err + } + + output := StartSessionOutput{ + SessionID: session.ID, + Status: session.Status, + PID: session.PID, + Message: "Session started successfully", + } + + return nil, output, nil +} + +// GetSessionStatusInput represents the input for getting session status +type GetSessionStatusInput struct { + SessionID string `json:"session_id" jsonschema:"the session ID to check"` +} + +// GetSessionStatusOutput represents the output from getting session status +type GetSessionStatusOutput struct { + SessionID string `json:"session_id" jsonschema:"the session ID"` + Status string `json:"status" jsonschema:"the session status: running, completed, failed, stopped, or not_found"` + PID string `json:"pid,omitempty" jsonschema:"the process ID"` + ExitCode string `json:"exit_code,omitempty" jsonschema:"the exit code if completed"` + CreatedAt time.Time `json:"created_at" jsonschema:"when the session was created"` + StartedAt time.Time `json:"started_at,omitempty" jsonschema:"when the session started"` + StoppedAt time.Time `json:"stopped_at,omitempty" jsonschema:"when the session stopped"` + Duration string `json:"duration,omitempty" jsonschema:"the session duration"` +} + +// GetSessionStatusHandler handles getting the status of a session +func GetSessionStatusHandler(ctx context.Context, req *mcp.CallToolRequest, input GetSessionStatusInput) (*mcp.CallToolResult, GetSessionStatusOutput, error) { + if globalSessionManager == nil { + return nil, GetSessionStatusOutput{}, fmt.Errorf("session manager not initialized") + } + + session, exists := globalSessionManager.GetSession(input.SessionID) + if !exists { + return nil, GetSessionStatusOutput{ + SessionID: input.SessionID, + Status: "not_found", + }, nil + } + + output := GetSessionStatusOutput{ + SessionID: session.ID, + Status: session.Status, + PID: session.PID, + ExitCode: session.ExitCode, + CreatedAt: session.CreatedAt, + StartedAt: session.StartedAt, + StoppedAt: session.StoppedAt, + } + + // Calculate duration + if !session.StartedAt.IsZero() { + endTime := session.StoppedAt + if endTime.IsZero() { + endTime = time.Now() + } + duration := endTime.Sub(session.StartedAt) + output.Duration = duration.String() + } + + return nil, output, nil +} + +// GetSessionLogsInput represents the input for getting session logs +type GetSessionLogsInput struct { + SessionID string `json:"session_id" jsonschema:"the session ID"` + Lines int `json:"lines,omitempty" jsonschema:"number of lines to retrieve from the end (default: 100)"` +} + +// GetSessionLogsOutput represents the output from getting session logs +type GetSessionLogsOutput struct { + SessionID string `json:"session_id" jsonschema:"the session ID"` + Stdout string `json:"stdout" jsonschema:"standard output from the session"` + Stderr string `json:"stderr" jsonschema:"standard error from the session"` + LineCount int `json:"line_count" jsonschema:"number of lines returned"` + TotalLines int `json:"total_lines,omitempty" jsonschema:"total number of lines available"` +} + +// GetSessionLogsHandler handles getting logs from a session +func GetSessionLogsHandler(ctx context.Context, req *mcp.CallToolRequest, input GetSessionLogsInput) (*mcp.CallToolResult, GetSessionLogsOutput, error) { + if globalSessionManager == nil { + return nil, GetSessionLogsOutput{}, fmt.Errorf("session manager not initialized") + } + + // Default to 100 lines if not specified + lines := input.Lines + if lines <= 0 { + lines = 100 + } + + stdout, stderr, err := globalSessionManager.GetSessionLogs(input.SessionID, lines) + if err != nil { + return nil, GetSessionLogsOutput{}, err + } + + output := GetSessionLogsOutput{ + SessionID: input.SessionID, + Stdout: stdout, + Stderr: stderr, + LineCount: lines, + } + + return nil, output, nil +} + +// StopSessionInput represents the input for stopping a session +type StopSessionInput struct { + SessionID string `json:"session_id" jsonschema:"the session ID to stop"` + Force bool `json:"force,omitempty" jsonschema:"force kill the process"` +} + +// StopSessionOutput represents the output from stopping a session +type StopSessionOutput struct { + SessionID string `json:"session_id" jsonschema:"the session ID"` + Status string `json:"status" jsonschema:"the session status after stopping"` + Message string `json:"message" jsonschema:"status message"` +} + +// StopSessionHandler handles stopping a session +func StopSessionHandler(ctx context.Context, req *mcp.CallToolRequest, input StopSessionInput) (*mcp.CallToolResult, StopSessionOutput, error) { + if globalSessionManager == nil { + return nil, StopSessionOutput{}, fmt.Errorf("session manager not initialized") + } + + err := globalSessionManager.StopSession(input.SessionID, input.Force) + if err != nil { + return nil, StopSessionOutput{}, err + } + + output := StopSessionOutput{ + SessionID: input.SessionID, + Status: "stopped", + Message: "Session stopped successfully", + } + + return nil, output, nil +} + +// ListSessionsInput represents the input for listing sessions +type ListSessionsInput struct { + StatusFilter string `json:"status_filter,omitempty" jsonschema:"filter by status: running, completed, failed, stopped, or all"` +} + +// SessionInfo represents a session in the list +type SessionInfo struct { + ID string `json:"id" jsonschema:"the session ID"` + Status string `json:"status" jsonschema:"the session status"` + PID string `json:"pid,omitempty" jsonschema:"the process ID"` + MessagePreview string `json:"message_preview" jsonschema:"preview of the message"` + CreatedAt time.Time `json:"created_at" jsonschema:"when the session was created"` + Model string `json:"model,omitempty" jsonschema:"the model used"` +} + +// ListSessionsOutput represents the output from listing sessions +type ListSessionsOutput struct { + Sessions []SessionInfo `json:"sessions" jsonschema:"list of sessions"` + Count int `json:"count" jsonschema:"number of sessions"` +} + +// ListSessionsHandler handles listing all sessions +func ListSessionsHandler(ctx context.Context, req *mcp.CallToolRequest, input ListSessionsInput) (*mcp.CallToolResult, ListSessionsOutput, error) { + if globalSessionManager == nil { + return nil, ListSessionsOutput{}, fmt.Errorf("session manager not initialized") + } + + sessions := globalSessionManager.ListSessions(input.StatusFilter) + + sessionInfos := []SessionInfo{} + for _, session := range sessions { + // Create message preview (first 100 chars) + preview := session.Message + if len(preview) > 100 { + preview = preview[:100] + "..." + } + + sessionInfos = append(sessionInfos, SessionInfo{ + ID: session.ID, + Status: session.Status, + PID: session.PID, + MessagePreview: preview, + CreatedAt: session.CreatedAt, + Model: session.Model, + }) + } + + output := ListSessionsOutput{ + Sessions: sessionInfos, + Count: len(sessionInfos), + } + + return nil, output, nil +} diff --git a/claude/main.go b/claude/main.go new file mode 100644 index 0000000..1fd7e7d --- /dev/null +++ b/claude/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "log" + "os" + "strconv" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + processmanager "github.com/mudler/go-processmanager" +) + +// Session represents an active Claude session +type Session struct { + ID string `json:"id"` + Status string `json:"status"` + PID string `json:"pid"` + Message string `json:"message"` + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + StartedAt time.Time `json:"started_at,omitempty"` + StoppedAt time.Time `json:"stopped_at,omitempty"` + ExitCode string `json:"exit_code"` + Process *processmanager.Process `json:"-"` + StateDir string `json:"state_dir"` +} + +// SessionManager manages all Claude sessions +type SessionManager struct { + sessions map[string]*Session + mutex sync.RWMutex + sessionDir, workDir string + maxSessions int +} + +// Global session manager +var globalSessionManager *SessionManager + +// getEnv gets environment variable with default value +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvInt gets environment variable as int with default value +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + } + return defaultValue +} + +func main() { + // Initialize session manager + sessionDir := getEnv("CLAUDE_SESSION_DIR", "/tmp/claude-sessions") + maxSessions := getEnvInt("CLAUDE_MAX_SESSIONS", 10) + workDir := getEnv("CLAUDE_WORK_DIR", "/root") + + // Ensure session directory exists + if err := os.MkdirAll(sessionDir, 0755); err != nil { + log.Fatalf("Failed to create session directory: %v", err) + } + + globalSessionManager = &SessionManager{ + sessions: make(map[string]*Session), + sessionDir: sessionDir, + workDir: workDir, + maxSessions: maxSessions, + } + + // Create MCP server + server := mcp.NewServer(&mcp.Implementation{ + Name: "claude", + Version: "v1.0.0", + }, nil) + + startSessionName := os.Getenv("CLAUDE_TOOL_START_SESSION_NAME") + if startSessionName == "" { + startSessionName = "start_session" + } + + getSessionStatusName := os.Getenv("CLAUDE_TOOL_GET_SESSION_STATUS_NAME") + if getSessionStatusName == "" { + getSessionStatusName = "get_session_status" + } + + getSessionLogsName := os.Getenv("CLAUDE_TOOL_GET_SESSION_LOGS_NAME") + if getSessionLogsName == "" { + getSessionLogsName = "get_session_logs" + } + + stopSessionName := os.Getenv("CLAUDE_TOOL_STOP_SESSION_NAME") + if stopSessionName == "" { + stopSessionName = "stop_session" + } + + listSessionsName := os.Getenv("CLAUDE_TOOL_LIST_SESSIONS_NAME") + if listSessionsName == "" { + listSessionsName = "list_sessions" + } + + // Register tools + mcp.AddTool(server, &mcp.Tool{ + Name: startSessionName, + Description: "Start a new Claude session with a prompt. Returns a session ID that can be used to check status and retrieve logs.", + }, StartSessionHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: getSessionStatusName, + Description: "Get the status of a Claude session by ID. Returns running, completed, failed, or not_found.", + }, GetSessionStatusHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: getSessionLogsName, + Description: "Get the stdout and stderr logs from a Claude session. Optionally specify the number of lines to retrieve (default 100).", + }, GetSessionLogsHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: stopSessionName, + Description: "Stop a running Claude session by ID. Optionally force kill the process.", + }, StopSessionHandler) + + mcp.AddTool(server, &mcp.Tool{ + Name: listSessionsName, + Description: "List all Claude sessions. Optionally filter by status: running, completed, failed, or all.", + }, ListSessionsHandler) + + // Run server + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } + + // Cleanup all sessions on shutdown + globalSessionManager.StopAllSessions() +} diff --git a/claude/session.go b/claude/session.go new file mode 100644 index 0000000..7a1bf87 --- /dev/null +++ b/claude/session.go @@ -0,0 +1,294 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + processmanager "github.com/mudler/go-processmanager" +) + +// SessionManager methods + +// CreateSession creates a new session and starts the Claude process +func (sm *SessionManager) CreateSession(message, title, sessionID string, files []string, useContinue, thinking bool) (*Session, error) { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + // Check max sessions limit + if len(sm.sessions) >= sm.maxSessions { + return nil, fmt.Errorf("maximum number of sessions (%d) reached", sm.maxSessions) + } + + // Generate unique session ID + id := uuid.New().String() + + // Create session directory + sessionDir := filepath.Join(sm.sessionDir, id) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create session directory: %w", err) + } + + // Get Claude binary path and configuration from environment + claudeBinary := getEnv("CLAUDE_BINARY", "claude") + model := getEnv("CLAUDE_MODEL", "") + agent := getEnv("CLAUDE_AGENT", "") + format := getEnv("CLAUDE_FORMAT", "json") + share := getEnv("CLAUDE_SHARE", "false") + attach := getEnv("CLAUDE_ATTACH", "") + portStr := getEnv("CLAUDE_PORT", "0") + variant := getEnv("CLAUDE_VARIANT", "") + + port, _ := strconv.Atoi(portStr) + + // Build command arguments + args := []string{"-p"} // Print mode for non-interactive execution + + // Add message if provided + if message != "" { + args = append(args, message) + } + + // Add optional flags (from environment variables) + if model != "" { + args = append(args, "-m", model) + } + if agent != "" { + args = append(args, "--agent", agent) + } + if format != "" { + args = append(args, "--format", format) + } + if title != "" { + args = append(args, "--title", title) + } + if sessionID != "" { + args = append(args, "-s", sessionID) + } + if useContinue { + args = append(args, "-c") + } + if share == "true" { + args = append(args, "--share") + } + if attach != "" { + args = append(args, "--attach", attach) + } + if port > 0 { + args = append(args, "--port", strconv.Itoa(port)) + } + if variant != "" { + args = append(args, "--variant", variant) + } + if thinking { + args = append(args, "--thinking") + } + if len(files) > 0 { + for _, file := range files { + args = append(args, "-f", file) + } + } + + // Add allowed tools if specified + allowedTools := os.Getenv("CLAUDE_ALLOWED_TOOLS") + if allowedTools != "" { + args = append(args, "--allowedTools", allowedTools) + } + + // Add tools restriction if specified + tools := os.Getenv("CLAUDE_TOOLS") + if tools != "" { + args = append(args, "--tools", tools) + } + + // Create process with go-processmanager + process := processmanager.New( + processmanager.WithName(claudeBinary), + processmanager.WithArgs(args...), + processmanager.WithStateDir(sessionDir), + processmanager.WithWorkDir(sm.workDir), + processmanager.WithEnvironment(os.Environ()...), + ) + + session := &Session{ + ID: id, + Status: "starting", + Message: message, + Model: getEnv("CLAUDE_MODEL", ""), + CreatedAt: time.Now(), + Process: process, + StateDir: sessionDir, + } + + sm.sessions[id] = session + + // Start the process asynchronously + go sm.runSession(session) + + return session, nil +} + +// runSession runs the Claude process and monitors its status +func (sm *SessionManager) runSession(session *Session) { + session.StartedAt = time.Now() + session.Status = "running" + + // Run the process + err := session.Process.Run() + if err != nil { + session.Status = "failed" + session.ExitCode = "-1" + session.StoppedAt = time.Now() + return + } + + session.PID = session.Process.PID + + // Wait for process to complete by polling + for { + time.Sleep(100 * time.Millisecond) + exitCode, err := session.Process.ExitCode() + if err == nil && exitCode != "" { + session.ExitCode = exitCode + break + } + } + + session.StoppedAt = time.Now() + + if session.ExitCode == "0" { + session.Status = "completed" + } else { + session.Status = "failed" + } + + // Schedule cleanup based on retention policy + go sm.scheduleCleanup(session.ID) +} + +// GetSession retrieves a session by ID +func (sm *SessionManager) GetSession(id string) (*Session, bool) { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + session, exists := sm.sessions[id] + return session, exists +} + +// StopSession stops a running session +func (sm *SessionManager) StopSession(id string, force bool) error { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + session, exists := sm.sessions[id] + if !exists { + return fmt.Errorf("session not found: %s", id) + } + + if session.Status != "running" && session.Status != "starting" { + return fmt.Errorf("session is not running: %s", session.Status) + } + + if err := session.Process.Stop(); err != nil { + return err + } + + session.Status = "stopped" + session.StoppedAt = time.Now() + return nil +} + +// GetSessionLogs retrieves stdout and stderr logs from a session +func (sm *SessionManager) GetSessionLogs(id string, lines int) (stdout, stderr string, err error) { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + + session, exists := sm.sessions[id] + if !exists { + return "", "", fmt.Errorf("session not found: %s", id) + } + + stdoutPath := session.Process.StdoutPath() + stderrPath := session.Process.StderrPath() + + stdoutBytes, err := os.ReadFile(stdoutPath) + if err != nil && !os.IsNotExist(err) { + return "", "", fmt.Errorf("failed to read stdout: %w", err) + } + + stderrBytes, err := os.ReadFile(stderrPath) + if err != nil && !os.IsNotExist(err) { + return "", "", fmt.Errorf("failed to read stderr: %w", err) + } + + stdout = string(stdoutBytes) + stderr = string(stderrBytes) + + // Limit to specified number of lines (from end) + if lines > 0 { + stdout = getLastNLines(stdout, lines) + stderr = getLastNLines(stderr, lines) + } + + return stdout, stderr, nil +} + +// ListSessions returns all sessions, optionally filtered by status +func (sm *SessionManager) ListSessions(statusFilter string) []*Session { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + + var result []*Session + for _, session := range sm.sessions { + if statusFilter == "" || statusFilter == "all" || session.Status == statusFilter { + result = append(result, session) + } + } + return result +} + +// StopAllSessions stops all running sessions (called on server shutdown) +func (sm *SessionManager) StopAllSessions() { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + for _, session := range sm.sessions { + if session.Status == "running" || session.Status == "starting" { + session.Process.Stop() + } + // Clean up session directory + os.RemoveAll(session.StateDir) + } + // Clear sessions map + sm.sessions = make(map[string]*Session) +} + +// scheduleCleanup schedules cleanup of old session based on retention policy +func (sm *SessionManager) scheduleCleanup(sessionID string) { + retentionHours := getEnvInt("CLAUDE_LOG_RETENTION_HOURS", 24) + if retentionHours <= 0 { + return // No cleanup scheduled + } + + time.AfterFunc(time.Duration(retentionHours)*time.Hour, func() { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + if session, exists := sm.sessions[sessionID]; exists { + os.RemoveAll(session.StateDir) + delete(sm.sessions, sessionID) + } + }) +} + +// getLastNLines returns the last n lines of a string +func getLastNLines(s string, n int) string { + lines := strings.Split(s, "\n") + if len(lines) <= n { + return s + } + return strings.Join(lines[len(lines)-n:], "\n") +}