summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-11 20:12:54 +0200
committerPaul Buetow <paul@buetow.org>2026-02-11 20:12:54 +0200
commit0a218306f8b3381610d219deca10a21406aa08cf (patch)
tree5ccbd55c7fc19234b0bd60668ee679d3cac40ea5 /internal
parentae38b11a09964e2c291a144c72814559d12d3b96 (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.go58
-rw-r--r--internal/mcp/transport.go62
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
}