diff --git a/internal/jsonrpc2/messages.go b/internal/jsonrpc2/messages.go index 77c95313..8b967706 100644 --- a/internal/jsonrpc2/messages.go +++ b/internal/jsonrpc2/messages.go @@ -171,8 +171,20 @@ func EncodeIndent(msg Message, prefix, indent string) ([]byte, error) { return bytes.TrimRight(buf.Bytes(), "\n"), nil } +// wireDecode is the decode form of [wireCombined]. Method is a [json.RawMessage] +// so we can tell whether the "method" key was present on the wire, including +// when its value is the empty string (see go-sdk#976). +type wireDecode struct { + VersionTag string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *WireError `json:"error,omitempty"` +} + func DecodeMessage(data []byte) (Message, error) { - msg := wireCombined{} + msg := wireDecode{} if err := internaljson.Unmarshal(data, &msg); err != nil { return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) } @@ -183,15 +195,19 @@ func DecodeMessage(data []byte) (Message, error) { if err != nil { return nil, err } - if msg.Method != "" { - // has a method, must be a call + if len(msg.Method) > 0 { + // The "method" key was present. Decode its value (including ""). + var method string + if err := internaljson.Unmarshal(msg.Method, &method); err != nil { + return nil, fmt.Errorf("unmarshaling jsonrpc message: %w", err) + } return &Request{ - Method: msg.Method, + Method: method, ID: id, Params: msg.Params, }, nil } - // no method, should be a response + // no method key, should be a response if !id.IsValid() { return nil, ErrInvalidRequest } diff --git a/internal/jsonrpc2/wire_test.go b/internal/jsonrpc2/wire_test.go index 6f576f08..cf7e2b86 100644 --- a/internal/jsonrpc2/wire_test.go +++ b/internal/jsonrpc2/wire_test.go @@ -102,6 +102,51 @@ func newResponse(id any, result any, rerr error) jsonrpc2.Message { return msg } +// go-sdk#976: empty method must decode as a request (encode omits empty method). +func TestDecodeEmptyMethodRequest(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":5,"method":"","params":{}}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + req, ok := msg.(*jsonrpc2.Request) + if !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Request", msg) + } + if req.Method != "" { + t.Errorf("Method = %q, want empty string", req.Method) + } + if req.ID != jsonrpc2.Int64ID(5) { + t.Errorf("ID = %v, want 5", req.ID.Raw()) + } + if !req.IsCall() { + t.Error("empty method with id=5 should be a call") + } +} + +func TestDecodeResponseUnchanged(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":2,"result":{}}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + if _, ok := msg.(*jsonrpc2.Response); !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Response", msg) + } +} + +// Messages with an id but no "method" key are responses, not malformed requests. +func TestDecodeIDOnlyMessageIsResponse(t *testing.T) { + encoded := []byte(`{"jsonrpc":"2.0","id":5}`) + msg, err := jsonrpc2.DecodeMessage(encoded) + if err != nil { + t.Fatal(err) + } + if _, ok := msg.(*jsonrpc2.Response); !ok { + t.Fatalf("message type = %T, want *jsonrpc2.Response", msg) + } +} + func checkJSON(t *testing.T, got, want []byte) { // compare the compact form, to allow for formatting differences g := &bytes.Buffer{} diff --git a/mcp/transport_test.go b/mcp/transport_test.go index 515b8c19..730400e2 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -124,3 +124,26 @@ func TestIOConnRead(t *testing.T) { }) } } + +// go-sdk#976: stdio must surface empty-method calls as requests, not responses. +func TestIOConnRead_EmptyMethod(t *testing.T) { + tr := newIOConn(rwc{ + rc: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":5,"method":"","params":{}}`)), + }) + t.Cleanup(func() { tr.Close() }) + + msg, err := tr.Read(context.Background()) + if err != nil { + t.Fatalf("ioConn.Read() error = %v", err) + } + req, ok := msg.(*jsonrpc.Request) + if !ok { + t.Fatalf("message type = %T, want *jsonrpc.Request", msg) + } + if req.Method != "" { + t.Errorf("Method = %q, want empty string", req.Method) + } + if req.ID != jsonrpc2.Int64ID(5) { + t.Errorf("ID = %v, want 5", req.ID.Raw()) + } +}