Skip to content

Commit 6b03643

Browse files
committed
fix: bound stdio JSON-RPC message size
1 parent 4dc99b4 commit 6b03643

3 files changed

Lines changed: 153 additions & 22 deletions

File tree

mcp/cmd.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ var defaultTerminateDuration = 5 * time.Second // mutable for testing
1919
// with it over stdin/stdout, using newline-delimited JSON.
2020
type CommandTransport struct {
2121
Command *exec.Cmd
22+
// MaxMessageBytes, if positive, rejects incoming JSON-RPC message payloads
23+
// from the subprocess larger than this many bytes. The line ending is not
24+
// counted.
25+
MaxMessageBytes int64
2226
// TerminateDuration controls how long Close waits after closing stdin
2327
// for the process to exit before sending SIGTERM.
2428
// If zero or negative, the default of 5s is used.
@@ -43,7 +47,7 @@ func (t *CommandTransport) Connect(ctx context.Context) (Connection, error) {
4347
if td <= 0 {
4448
td = defaultTerminateDuration
4549
}
46-
return newIOConn(&pipeRWC{t.Command, stdout, stdin, td}), nil
50+
return newIOConnWithOptions(&pipeRWC{t.Command, stdout, stdin, td}, t.MaxMessageBytes), nil
4751
}
4852

4953
// A pipeRWC is an io.ReadWriteCloser that communicates with a subprocess over

mcp/transport.go

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package mcp
66

77
import (
8+
"bufio"
9+
"bytes"
810
"context"
911
"encoding/json"
1012
"errors"
@@ -111,11 +113,15 @@ type serverConnection interface {
111113

112114
// A StdioTransport is a [Transport] that communicates over stdin/stdout using
113115
// newline-delimited JSON.
114-
type StdioTransport struct{}
116+
type StdioTransport struct {
117+
// MaxMessageBytes, if positive, rejects incoming JSON-RPC message payloads
118+
// larger than this many bytes. The line ending is not counted.
119+
MaxMessageBytes int64
120+
}
115121

116122
// Connect implements the [Transport] interface.
117-
func (*StdioTransport) Connect(context.Context) (Connection, error) {
118-
return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil
123+
func (t *StdioTransport) Connect(context.Context) (Connection, error) {
124+
return newIOConnWithOptions(rwc{os.Stdin, nopCloserWriter{os.Stdout}}, t.MaxMessageBytes), nil
119125
}
120126

121127
// nopCloserWriter is an io.WriteCloser with a trivial Close method.
@@ -130,11 +136,14 @@ func (nopCloserWriter) Close() error { return nil }
130136
type IOTransport struct {
131137
Reader io.ReadCloser
132138
Writer io.WriteCloser
139+
// MaxMessageBytes, if positive, rejects incoming JSON-RPC message payloads
140+
// larger than this many bytes. The line ending is not counted.
141+
MaxMessageBytes int64
133142
}
134143

135144
// Connect implements the [Transport] interface.
136145
func (t *IOTransport) Connect(context.Context) (Connection, error) {
137-
return newIOConn(rwc{t.Reader, t.Writer}), nil
146+
return newIOConnWithOptions(rwc{t.Reader, t.Writer}, t.MaxMessageBytes), nil
138147
}
139148

140149
// An InMemoryTransport is a [Transport] that communicates over an in-memory
@@ -405,6 +414,10 @@ type msgOrErr struct {
405414
}
406415

407416
func newIOConn(rwc io.ReadWriteCloser) *ioConn {
417+
return newIOConnWithOptions(rwc, 0)
418+
}
419+
420+
func newIOConnWithOptions(rwc io.ReadWriteCloser, maxMessageBytes int64) *ioConn {
408421
var (
409422
incoming = make(chan msgOrErr)
410423
closed = make(chan struct{})
@@ -416,24 +429,9 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn {
416429
// but that is unavoidable since AFAIK there is no (easy and portable) way to
417430
// guarantee that reads of stdin are unblocked when closed.
418431
go func() {
419-
dec := json.NewDecoder(rwc)
432+
reader := bufio.NewReader(rwc)
420433
for {
421-
var raw json.RawMessage
422-
err := dec.Decode(&raw)
423-
// If decoding was successful, check for trailing data at the end of the stream.
424-
if err == nil {
425-
// Read the next byte to check if there is trailing data.
426-
var tr [1]byte
427-
if n, readErr := dec.Buffered().Read(tr[:]); n > 0 {
428-
// If read byte is not a newline, it is an error.
429-
// Support both Unix (\n) and Windows (\r\n) line endings.
430-
if tr[0] != '\n' && tr[0] != '\r' {
431-
err = fmt.Errorf("invalid trailing data at the end of stream")
432-
}
433-
} else if readErr != nil && readErr != io.EOF {
434-
err = readErr
435-
}
436-
}
434+
raw, err := readFrame(reader, maxMessageBytes)
437435
select {
438436
case incoming <- msgOrErr{msg: raw, err: err}:
439437
case <-closed:
@@ -451,6 +449,66 @@ func newIOConn(rwc io.ReadWriteCloser) *ioConn {
451449
}
452450
}
453451

452+
func readFrame(reader *bufio.Reader, maxMessageBytes int64) (json.RawMessage, error) {
453+
var frame []byte
454+
for {
455+
part, err := reader.ReadSlice('\n')
456+
frame = append(frame, part...)
457+
switch {
458+
case err == nil:
459+
frame = trimLineEnding(frame)
460+
if maxMessageBytes > 0 && int64(len(frame)) > maxMessageBytes {
461+
return nil, fmt.Errorf("JSON-RPC message exceeds maximum size of %d bytes", maxMessageBytes)
462+
}
463+
if err := validateJSONFrame(frame); err != nil {
464+
return nil, err
465+
}
466+
return json.RawMessage(frame), nil
467+
case errors.Is(err, bufio.ErrBufferFull):
468+
if maxMessageBytes > 0 && int64(len(frame)) > maxMessageBytes {
469+
return nil, fmt.Errorf("JSON-RPC message exceeds maximum size of %d bytes", maxMessageBytes)
470+
}
471+
continue
472+
case errors.Is(err, io.EOF):
473+
if len(frame) == 0 {
474+
return nil, io.EOF
475+
}
476+
if maxMessageBytes > 0 && int64(len(frame)) > maxMessageBytes {
477+
return nil, fmt.Errorf("JSON-RPC message exceeds maximum size of %d bytes", maxMessageBytes)
478+
}
479+
if err := validateJSONFrame(frame); err != nil {
480+
return nil, err
481+
}
482+
return json.RawMessage(frame), nil
483+
default:
484+
return nil, err
485+
}
486+
}
487+
}
488+
489+
func trimLineEnding(frame []byte) []byte {
490+
if n := len(frame); n > 0 && frame[n-1] == '\n' {
491+
frame = frame[:n-1]
492+
}
493+
if n := len(frame); n > 0 && frame[n-1] == '\r' {
494+
frame = frame[:n-1]
495+
}
496+
return frame
497+
}
498+
499+
func validateJSONFrame(frame []byte) error {
500+
dec := json.NewDecoder(bytes.NewReader(frame))
501+
var raw json.RawMessage
502+
if err := dec.Decode(&raw); err != nil {
503+
return err
504+
}
505+
var extra json.RawMessage
506+
if err := dec.Decode(&extra); err != io.EOF {
507+
return fmt.Errorf("invalid trailing data at the end of stream")
508+
}
509+
return nil
510+
}
511+
454512
func (c *ioConn) SessionID() string { return "" }
455513

456514
func (c *ioConn) sessionUpdated(state ServerSessionState) {

mcp/transport_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,72 @@ func TestIOConnRead(t *testing.T) {
124124
})
125125
}
126126
}
127+
128+
func TestIOConnReadMaxMessageBytes(t *testing.T) {
129+
ctx := context.Background()
130+
input := `{"jsonrpc":"2.0","id":1,"method":"test","params":{}}`
131+
132+
t.Run("allows frame at limit", func(t *testing.T) {
133+
tr := newIOConnWithOptions(rwc{
134+
rc: io.NopCloser(strings.NewReader(input)),
135+
}, int64(len(input)))
136+
t.Cleanup(func() { tr.Close() })
137+
138+
msg, err := tr.Read(ctx)
139+
if err != nil {
140+
t.Fatalf("Read() returned error: %v", err)
141+
}
142+
if got := msg.(*jsonrpc.Request).Method; got != "test" {
143+
t.Fatalf("Read() method = %q, want test", got)
144+
}
145+
})
146+
147+
t.Run("allows line ending beyond limit", func(t *testing.T) {
148+
for _, lineEnding := range []string{"\n", "\r\n"} {
149+
tr := newIOConnWithOptions(rwc{
150+
rc: io.NopCloser(strings.NewReader(input + lineEnding)),
151+
}, int64(len(input)))
152+
t.Cleanup(func() { tr.Close() })
153+
154+
msg, err := tr.Read(ctx)
155+
if err != nil {
156+
t.Fatalf("Read() with line ending %q returned error: %v", lineEnding, err)
157+
}
158+
if got := msg.(*jsonrpc.Request).Method; got != "test" {
159+
t.Fatalf("Read() method = %q, want test", got)
160+
}
161+
}
162+
})
163+
164+
t.Run("rejects frame over limit", func(t *testing.T) {
165+
tr := newIOConnWithOptions(rwc{
166+
rc: io.NopCloser(strings.NewReader(input)),
167+
}, int64(len(input)-1))
168+
t.Cleanup(func() { tr.Close() })
169+
170+
_, err := tr.Read(ctx)
171+
if err == nil {
172+
t.Fatal("Read() returned nil error")
173+
}
174+
want := "JSON-RPC message exceeds maximum size"
175+
if !strings.Contains(err.Error(), want) {
176+
t.Fatalf("Read() error = %q, want substring %q", err, want)
177+
}
178+
})
179+
180+
t.Run("keeps zero limit unbounded", func(t *testing.T) {
181+
longInput := `{"jsonrpc":"2.0","id":1,"method":"` + strings.Repeat("x", 8192) + `","params":{}}`
182+
tr := newIOConnWithOptions(rwc{
183+
rc: io.NopCloser(strings.NewReader(longInput)),
184+
}, 0)
185+
t.Cleanup(func() { tr.Close() })
186+
187+
msg, err := tr.Read(ctx)
188+
if err != nil {
189+
t.Fatalf("Read() returned error: %v", err)
190+
}
191+
if got := msg.(*jsonrpc.Request).Method; got != strings.Repeat("x", 8192) {
192+
t.Fatalf("Read() method length = %d, want 8192", len(got))
193+
}
194+
})
195+
}

0 commit comments

Comments
 (0)