// Copyright 2026 MarketAlly. All rights reserved. // SPDX-License-Identifier: MIT // GitCaddy MCP Server - Model Context Protocol server for GitCaddy/Gitea Actions // // This standalone server implements the MCP protocol over stdio, // proxying requests to a GitCaddy instance's /api/v2/mcp endpoint. // // Usage: // // gitcaddy-mcp-server --url https://git.example.com --token YOUR_API_TOKEN // // Configure in Claude Code's settings.json: // // { // "mcpServers": { // "gitcaddy": { // "command": "gitcaddy-mcp-server", // "args": ["--url", "https://git.example.com", "--token", "YOUR_TOKEN"] // } // } // } package main import ( "bufio" "bytes" "encoding/json" "flag" "fmt" "io" "net/http" "os" "time" ) var ( giteaURL string giteaToken string debug bool ) func main() { flag.StringVar(&giteaURL, "url", "", "GitCaddy server URL (e.g., https://git.example.com)") flag.StringVar(&giteaToken, "token", "", "GitCaddy API token") flag.BoolVar(&debug, "debug", false, "Enable debug logging to stderr") flag.Parse() // Check environment variables (GITCADDY_* preferred, GITEA_* for backwards compat) if giteaURL == "" { giteaURL = os.Getenv("GITCADDY_URL") } if giteaURL == "" { giteaURL = os.Getenv("GITEA_URL") } if giteaToken == "" { giteaToken = os.Getenv("GITCADDY_TOKEN") } if giteaToken == "" { giteaToken = os.Getenv("GITEA_TOKEN") } if giteaURL == "" { fmt.Fprintln(os.Stderr, "Error: --url or GITCADDY_URL is required") os.Exit(1) } debugLog("GitCaddy MCP Server starting") debugLog("Connecting to: %s", giteaURL) // Read JSON-RPC messages from stdin, forward to Gitea, write responses to stdout // Supports both raw JSON lines and Content-Length framed messages (Claude Code uses framing) reader := bufio.NewReader(os.Stdin) for { line, err := readMessage(reader) if err != nil { if err == io.EOF { debugLog("EOF received, exiting") break } debugLog("Read error: %v", err) continue } line = bytes.TrimSpace(line) if len(line) == 0 { continue } debugLog("Received: %s", string(line)) // JSON-RPC notifications (no "id" field) must NEVER receive a response. // The MCP spec defines notifications/initialized, notifications/cancelled, // notifications/progress, etc. — these are fire-and-forget. Even if the // remote gitcaddy server returns an error for an unknown notification, we // must suppress it on the way back to the client (Claude Code), otherwise // the client sees an unexpected response and fails the handshake. isNotification := isJsonRpcNotification(line) // Forward to Gitea's MCP endpoint response, err := forwardToGitea(line) if err != nil { debugLog("Forward error: %v", err) if isNotification { // Notification — no response allowed, just swallow. continue } // Send error response errorResp := map[string]interface{}{ "jsonrpc": "2.0", "id": nil, "error": map[string]interface{}{ "code": -32603, "message": "Internal error", "data": err.Error(), }, } writeResponse(errorResp) continue } debugLog("Response: %s", string(response)) if isNotification { // Notification — never respond, even if the remote sent something. debugLog("Suppressing response to notification") continue } // Write response to stdout as newline-delimited JSON writeFramed(response) } } // isJsonRpcNotification reports whether a raw JSON-RPC message is a notification // (a request without an "id" field). Per JSON-RPC 2.0, servers MUST NOT reply to // notifications. func isJsonRpcNotification(raw []byte) bool { var probe map[string]json.RawMessage if err := json.Unmarshal(raw, &probe); err != nil { return false } _, hasId := probe["id"] _, hasMethod := probe["method"] return hasMethod && !hasId } func forwardToGitea(request []byte) ([]byte, error) { mcpURL := giteaURL + "/api/v2/mcp" req, err := http.NewRequest("POST", mcpURL, bytes.NewReader(request)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if giteaToken != "" { req.Header.Set("Authorization", "token "+giteaToken) } client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("http request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("http status %d: %s", resp.StatusCode, string(body)) } return body, nil } // readMessage reads a JSON-RPC message from stdin. // Handles both raw JSON lines and Content-Length framed messages. // Claude Code sends: Content-Length: N\r\n\r\n{json} func readMessage(reader *bufio.Reader) ([]byte, error) { // Peek at first bytes to detect format for { line, err := reader.ReadBytes('\n') if err != nil { return nil, err } trimmed := bytes.TrimSpace(line) if len(trimmed) == 0 { continue } // If it starts with '{', it's a raw JSON line if trimmed[0] == '{' { return trimmed, nil } // If it starts with "Content-Length:", read the framed message if bytes.HasPrefix(bytes.ToLower(trimmed), []byte("content-length:")) { // Parse content length parts := bytes.SplitN(trimmed, []byte(":"), 2) if len(parts) != 2 { continue } lengthStr := bytes.TrimSpace(parts[1]) var contentLength int fmt.Sscanf(string(lengthStr), "%d", &contentLength) if contentLength <= 0 { continue } // Read until empty line (end of headers) for { headerLine, err := reader.ReadBytes('\n') if err != nil { return nil, err } if len(bytes.TrimSpace(headerLine)) == 0 { break } } // Read exactly contentLength bytes body := make([]byte, contentLength) _, err := io.ReadFull(reader, body) if err != nil { return nil, fmt.Errorf("read body: %w", err) } return body, nil } // Unknown line, skip debugLog("Skipping unknown line: %s", string(trimmed)) } } func writeResponse(resp interface{}) { data, _ := json.Marshal(resp) writeFramed(data) } func writeFramed(data []byte) { fmt.Fprintf(os.Stdout, "Content-Length: %d\r\n\r\n%s", len(data), data) } func debugLog(format string, args ...interface{}) { if debug { fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...) } }