Skip to content

Commit d3ad908

Browse files
committed
fix: bound stdio JSON-RPC message size
1 parent bcbfe0c commit d3ad908

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
@@ -147,3 +147,72 @@ func TestIOConnRead_EmptyMethod(t *testing.T) {
147147
t.Errorf("ID = %v, want 5", req.ID.Raw())
148148
}
149149
}
150+
151+
func TestIOConnReadMaxMessageBytes(t *testing.T) {
152+
ctx := context.Background()
153+
input := `{"jsonrpc":"2.0","id":1,"method":"test","params":{}}`
154+
155+
t.Run("allows frame at limit", func(t *testing.T) {
156+
tr := newIOConnWithOptions(rwc{
157+
rc: io.NopCloser(strings.NewReader(input)),
158+
}, int64(len(input)))
159+
t.Cleanup(func() { tr.Close() })
160+
161+
msg, err := tr.Read(ctx)
162+
if err != nil {
163+
t.Fatalf("Read() returned error: %v", err)
164+
}
165+
if got := msg.(*jsonrpc.Request).Method; got != "test" {
166+
t.Fatalf("Read() method = %q, want test", got)
167+
}
168+
})
169+
170+
t.Run("allows line ending beyond limit", func(t *testing.T) {
171+
for _, lineEnding := range []string{"\n", "\r\n"} {
172+
tr := newIOConnWithOptions(rwc{
173+
rc: io.NopCloser(strings.NewReader(input + lineEnding)),
174+
}, int64(len(input)))
175+
t.Cleanup(func() { tr.Close() })
176+
177+
msg, err := tr.Read(ctx)
178+
if err != nil {
179+
t.Fatalf("Read() with line ending %q returned error: %v", lineEnding, err)
180+
}
181+
if got := msg.(*jsonrpc.Request).Method; got != "test" {
182+
t.Fatalf("Read() method = %q, want test", got)
183+
}
184+
}
185+
})
186+
187+
t.Run("rejects frame over limit", func(t *testing.T) {
188+
tr := newIOConnWithOptions(rwc{
189+
rc: io.NopCloser(strings.NewReader(input)),
190+
}, int64(len(input)-1))
191+
t.Cleanup(func() { tr.Close() })
192+
193+
_, err := tr.Read(ctx)
194+
if err == nil {
195+
t.Fatal("Read() returned nil error")
196+
}
197+
want := "JSON-RPC message exceeds maximum size"
198+
if !strings.Contains(err.Error(), want) {
199+
t.Fatalf("Read() error = %q, want substring %q", err, want)
200+
}
201+
})
202+
203+
t.Run("keeps zero limit unbounded", func(t *testing.T) {
204+
longInput := `{"jsonrpc":"2.0","id":1,"method":"` + strings.Repeat("x", 8192) + `","params":{}}`
205+
tr := newIOConnWithOptions(rwc{
206+
rc: io.NopCloser(strings.NewReader(longInput)),
207+
}, 0)
208+
t.Cleanup(func() { tr.Close() })
209+
210+
msg, err := tr.Read(ctx)
211+
if err != nil {
212+
t.Fatalf("Read() returned error: %v", err)
213+
}
214+
if got := msg.(*jsonrpc.Request).Method; got != strings.Repeat("x", 8192) {
215+
t.Fatalf("Read() method length = %d, want 8192", len(got))
216+
}
217+
})
218+
}

0 commit comments

Comments
 (0)