All checks were successful
Release / build (amd64, linux) (push) Successful in 38s
Release / build (amd64, windows) (push) Successful in 38s
Release / build (arm64, darwin) (push) Successful in 44s
Release / build (amd64, darwin) (push) Successful in 1m4s
Release / build (arm64, linux) (push) Successful in 1m14s
Release / release (push) Successful in 21s
Adds writeFramed helper to send responses with HTTP-style Content-Length headers instead of raw JSON lines. Ensures compatibility with Claude Code which expects framed messages. Updates both main loop and writeResponse to use consistent framing.
226 lines
5.4 KiB
Go
226 lines
5.4 KiB
Go
// 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))
|
|
|
|
// Forward to Gitea's MCP endpoint
|
|
response, err := forwardToGitea(line)
|
|
if err != nil {
|
|
debugLog("Forward error: %v", err)
|
|
// 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))
|
|
|
|
// Write response to stdout with Content-Length framing
|
|
writeFramed(response)
|
|
}
|
|
}
|
|
|
|
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...)
|
|
}
|
|
}
|