2
0

5 Commits

Author SHA1 Message Date
4eb7f78b6b fix(mcp): restore Content-Length framing for MCP stdio
All checks were successful
Release / build (amd64, windows) (push) Successful in 39s
Release / build (arm64, darwin) (push) Successful in 40s
Release / build (amd64, linux) (push) Successful in 52s
Release / build (amd64, darwin) (push) Successful in 1m27s
Release / build (arm64, linux) (push) Successful in 1m38s
Release / release (push) Successful in 24s
Revert to LSP-style Content-Length framing for MCP stdio transport. Testing confirmed that MCP clients (Claude Code) do expect Content-Length headers, not raw NDJSON. The earlier NDJSON change was based on incorrect assumption about the protocol.
2026-04-15 03:46:17 -04:00
c6ed87cdcf fix(mcp): suppress responses to JSON-RPC notifications
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
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
f059b6f458 fix(mcp): use NDJSON instead of LSP framing for MCP stdio
All checks were successful
Release / build (amd64, windows) (push) Successful in 43s
Release / build (arm64, linux) (push) Successful in 1m0s
Release / build (amd64, linux) (push) Successful in 1m10s
Release / build (amd64, darwin) (push) Successful in 1m19s
Release / build (arm64, darwin) (push) Successful in 1m46s
Release / release (push) Successful in 21s
Replace Content-Length framing with newline-delimited JSON (NDJSON) for MCP stdio transport. MCP clients like Claude Code expect one JSON object per line, not LSP-style Content-Length headers. The previous framing caused clients to reject responses.
2026-04-15 02:20:20 -04:00
4b350fe967 fix(mcp): use Content-Length framing for all responses
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.
2026-04-05 02:48:24 -04:00
1d770b45c9 feat(mcp): add support for Content-Length framed JSON-RPC messages
All checks were successful
Release / build (amd64, windows) (push) Successful in 41s
Release / build (arm64, darwin) (push) Successful in 42s
Release / build (amd64, linux) (push) Successful in 46s
Release / build (arm64, linux) (push) Successful in 1m11s
Release / release (push) Successful in 19s
Release / build (amd64, darwin) (push) Successful in 1m4s
Implements readMessage function to handle both raw JSON lines and Content-Length framed messages. Claude Code uses HTTP-style framing (Content-Length: N\r\n\r\n{json}) while other clients may send raw JSON. Auto-detects format by peeking at first bytes. Adds .gitignore for compiled binaries.
2026-04-05 02:24:17 -04:00
2 changed files with 105 additions and 4 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.exe
*.dll
*.so
*.dylib

105
main.go
View File

@@ -69,10 +69,11 @@ func main() {
debugLog("Connecting to: %s", giteaURL) debugLog("Connecting to: %s", giteaURL)
// Read JSON-RPC messages from stdin, forward to Gitea, write responses to stdout // 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) reader := bufio.NewReader(os.Stdin)
for { for {
line, err := reader.ReadBytes('\n') line, err := readMessage(reader)
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
debugLog("EOF received, exiting") debugLog("EOF received, exiting")
@@ -89,10 +90,22 @@ func main() {
debugLog("Received: %s", string(line)) 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 // Forward to Gitea's MCP endpoint
response, err := forwardToGitea(line) response, err := forwardToGitea(line)
if err != nil { if err != nil {
debugLog("Forward error: %v", err) debugLog("Forward error: %v", err)
if isNotification {
// Notification — no response allowed, just swallow.
continue
}
// Send error response // Send error response
errorResp := map[string]interface{}{ errorResp := map[string]interface{}{
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -109,11 +122,30 @@ func main() {
debugLog("Response: %s", string(response)) debugLog("Response: %s", string(response))
// Write response to stdout if isNotification {
fmt.Println(string(response)) // 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) { func forwardToGitea(request []byte) ([]byte, error) {
mcpURL := giteaURL + "/api/v2/mcp" mcpURL := giteaURL + "/api/v2/mcp"
@@ -147,9 +179,74 @@ func forwardToGitea(request []byte) ([]byte, error) {
return body, nil 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{}) { func writeResponse(resp interface{}) {
data, _ := json.Marshal(resp) data, _ := json.Marshal(resp)
fmt.Println(string(data)) 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{}) { func debugLog(format string, args ...interface{}) {