diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-11 20:12:54 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-11 20:12:54 +0200 |
| commit | 0a218306f8b3381610d219deca10a21406aa08cf (patch) | |
| tree | 5ccbd55c7fc19234b0bd60668ee679d3cac40ea5 /internal/mcp/transport.go | |
| parent | ae38b11a09964e2c291a144c72814559d12d3b96 (diff) | |
Fix MCP transport to use JSONL instead of Content-Length framing
The MCP stdio protocol uses newline-delimited JSON (JSONL), not LSP-style
Content-Length headers. This was discovered during integration testing with
Claude Code CLI which could not connect to the server.
Also updates docs/mcp-setup.md with correct Claude Code CLI configuration
(use `claude mcp add` instead of editing JSON files) and adds integration
test runbook for testing against real Claude Code CLI instances.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/mcp/transport.go')
| -rw-r--r-- | internal/mcp/transport.go | 62 |
1 files changed, 24 insertions, 38 deletions
diff --git a/internal/mcp/transport.go b/internal/mcp/transport.go index aba416a..4708e6d 100644 --- a/internal/mcp/transport.go +++ b/internal/mcp/transport.go @@ -1,54 +1,40 @@ -// Summary: MCP transport utilities for reading and writing JSON-RPC messages with Content-Length framing. +// Summary: MCP transport utilities for reading and writing JSON-RPC messages +// using newline-delimited JSON (JSONL) as required by the MCP stdio protocol. package mcp import ( "encoding/json" "fmt" "io" - "net/textproto" - "strconv" "strings" ) -// readMessage reads a Content-Length framed JSON-RPC message from the input stream. -// Returns the raw JSON bytes. Follows LSP/JSON-RPC framing convention. +// readMessage reads a newline-delimited JSON-RPC message from the input stream. +// Returns the raw JSON bytes. Follows the MCP stdio transport specification +// which uses newline-delimited JSON (JSONL), not LSP-style Content-Length framing. +// Uses the server's bufio.Reader (s.in) to avoid losing buffered data between calls. func (s *Server) readMessage() ([]byte, error) { - tp := textproto.NewReader(s.in) - var contentLength int for { - line, err := tp.ReadLine() - if err != nil { - return nil, err - } - if line == "" { // end of headers - break - } - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - continue + line, err := s.in.ReadString('\n') + if err != nil && len(line) == 0 { + if err == io.EOF { + return nil, io.EOF + } + return nil, fmt.Errorf("read error: %w", err) } - key := strings.TrimSpace(strings.ToLower(parts[0])) - val := strings.TrimSpace(parts[1]) - switch key { - case "content-length": - n, err := strconv.Atoi(val) - if err != nil { - return nil, fmt.Errorf("invalid Content-Length: %v", err) + line = strings.TrimSpace(line) + if line == "" { + // If we hit EOF on an empty line, propagate it + if err == io.EOF { + return nil, io.EOF } - contentLength = n + continue // skip empty lines } + return []byte(line), nil } - if contentLength <= 0 { - return nil, fmt.Errorf("missing or invalid Content-Length") - } - buf := make([]byte, contentLength) - if _, err := io.ReadFull(s.in, buf); err != nil { - return nil, err - } - return buf, nil } -// writeMessage writes a JSON-RPC response with Content-Length framing. +// writeMessage writes a JSON-RPC response as newline-delimited JSON. // Thread-safe via mutex lock. func (s *Server) writeMessage(v any) error { s.outMu.Lock() @@ -58,12 +44,12 @@ func (s *Server) writeMessage(v any) error { if err != nil { return fmt.Errorf("marshal error: %w", err) } - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) - if _, err := io.WriteString(s.out, header); err != nil { - return fmt.Errorf("write header error: %w", err) - } + // Write JSON followed by newline (JSONL format) if _, err := s.out.Write(data); err != nil { return fmt.Errorf("write body error: %w", err) } + if _, err := io.WriteString(s.out, "\n"); err != nil { + return fmt.Errorf("write newline error: %w", err) + } return nil } |
