summaryrefslogtreecommitdiff
path: root/internal/mcp/transport.go
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/mcp/transport.go
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/mcp/transport.go')
-rw-r--r--internal/mcp/transport.go62
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
}