2
0
Files
mcp-server/main.go
logikonline c6ed87cdcf
All checks were successful
Release / build (amd64, windows) (push) Successful in 39s
Release / build (amd64, linux) (push) Successful in 57s
Release / build (arm64, linux) (push) Successful in 1m4s
Release / build (amd64, darwin) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / release (push) Successful in 22s
fix(mcp): suppress responses to JSON-RPC notifications
Add JSON-RPC notification detection and response suppression per MCP spec. Notifications (requests without "id" field) must never receive responses, even if the upstream server returns an error. Prevents handshake failures when Claude Code sends notifications/initialized and receives unexpected error responses.

Changes:
- Add isJsonRpcNotification() to detect requests without "id" field
- Skip error responses for notifications
- Suppress upstream responses to notifications
- Add detailed comments explaining JSON-RPC 2.0 notification semantics
2026-04-15 02:27:22 -04:00

260 lines
6.8 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))
// 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) {
// MCP stdio transport uses newline-delimited JSON (NDJSON), not LSP-style
// Content-Length framing. Claude Code and other MCP clients reject
// Content-Length headers on stdio, so we emit one JSON object per line.
fmt.Fprintf(os.Stdout, "%s\n", data)
}
func debugLog(format string, args ...interface{}) {
if debug {
fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...)
}
}