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 | |
| 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')
| -rw-r--r-- | internal/mcp/server_test.go | 58 | ||||
| -rw-r--r-- | internal/mcp/transport.go | 62 |
2 files changed, 58 insertions, 62 deletions
diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 4a14ffc..0c767f2 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -57,43 +57,34 @@ func createTestServer(t *testing.T, store promptstore.PromptStore) (*Server, *by return NewServer(inBuf, outBuf, logger, store), inBuf, outBuf } +// sendRequest writes a JSON-RPC request as newline-delimited JSON (MCP stdio protocol). func sendRequest(w io.Writer, req Request) error { data, err := json.Marshal(req) if err != nil { return err } - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) - if _, err := io.WriteString(w, header); err != nil { + if _, err := w.Write(data); err != nil { return err } - if _, err := w.Write(data); err != nil { + if _, err := io.WriteString(w, "\n"); err != nil { return err } return nil } +// readResponse reads a newline-delimited JSON-RPC response (MCP stdio protocol). func readResponse(r io.Reader) (*Response, error) { - // Simple read for testing (assumes one message in buffer) data, err := io.ReadAll(r) if err != nil { return nil, err } - // Find Content-Length - lines := strings.Split(string(data), "\r\n") - var contentLength int - bodyStart := 0 - for i, line := range lines { - if strings.HasPrefix(line, "Content-Length:") { - fmt.Sscanf(line, "Content-Length: %d", &contentLength) - } - if line == "" { - bodyStart = i + 1 - break - } + // Parse newline-delimited JSON; take the last non-empty line as the response + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 0 { + return nil, fmt.Errorf("no response data") } - - body := strings.Join(lines[bodyStart:], "\r\n") + body := lines[len(lines)-1] var resp Response if err := json.Unmarshal([]byte(body), &resp); err != nil { return nil, fmt.Errorf("unmarshal response: %w, body: %s", err, body) @@ -459,11 +450,9 @@ func TestServer_ReadMessage(t *testing.T) { logger := log.New(io.Discard, "", 0) server := NewServer(inBuf, outBuf, logger, store) - // Write a message with proper framing - msg := []byte(`{"jsonrpc":"2.0","id":1,"method":"test"}`) - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg)) - inBuf.WriteString(header) - inBuf.Write(msg) + // Write a newline-delimited JSON message (MCP stdio protocol) + msg := `{"jsonrpc":"2.0","id":1,"method":"test"}` + inBuf.WriteString(msg + "\n") // Read it back body, err := server.readMessage() @@ -471,7 +460,28 @@ func TestServer_ReadMessage(t *testing.T) { t.Fatalf("readMessage() error = %v", err) } - if string(body) != string(msg) { + if string(body) != msg { + t.Errorf("readMessage() = %s, want %s", body, msg) + } + }) + + t.Run("skips empty lines", func(t *testing.T) { + store := &mockPromptStore{prompts: make(map[string]*promptstore.Prompt)} + inBuf := &bytes.Buffer{} + outBuf := &bytes.Buffer{} + logger := log.New(io.Discard, "", 0) + server := NewServer(inBuf, outBuf, logger, store) + + // Write empty lines followed by a valid message + msg := `{"jsonrpc":"2.0","id":1,"method":"test"}` + inBuf.WriteString("\n\n" + msg + "\n") + + body, err := server.readMessage() + if err != nil { + t.Fatalf("readMessage() error = %v", err) + } + + if string(body) != msg { t.Errorf("readMessage() = %s, want %s", body, msg) } }) 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 } |
