fix(jsonrpc2): decode requests when method key is present (#1000)

## Summary

Fixes #976.

`DecodeMessage` used `msg.Method != ""` to classify messages. A wire
payload with `"method":""` was decoded as a **response**, so stdio
servers dropped it without a correlated JSON-RPC error.

## Approach

Decode into a `wireDecode` struct whose `method` field is
`json.RawMessage`. Treat the message as a **request** when the `method`
key is **present on the wire** (including `""`), which matches JSON-RPC
semantics.

This revision:

- Uses a **single JSON parse** per message (dedicated decode struct, no
secondary fields-map unmarshal)
- Documents why `json.RawMessage` is used for `method`
- Adds regression coverage so normal responses and id-only payloads
without a `method` key stay on the response path

## Test plan

- [x] `go test ./internal/jsonrpc2/... -v`
- [x] `go test ./mcp/... -run TestIOConnRead_EmptyMethod -v`
- [x] `go test ./... -count=1`
- [x] `go test -race ./internal/jsonrpc2/... -count=1`
- [x] Server conformance (`npx @modelcontextprotocol/conformance server
--url http://127.0.0.1:3000/mcp`) — 40 passed, 0 failed
- [x] Client conformance (`./scripts/client-conformance.sh`) — 217
passed, 0 failed

## Scope note

This PR addresses the primary repro in #976 (empty `method` with valid
`id`). Other cases listed in the issue (`id: null`, structurally invalid
envelopes) are unchanged.

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
Piyush Bag 2026-06-16 00:57:18 -07:00 • committed by GitHub
commit bcbfe0ce36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 89 additions and 5 deletions

View file

@ -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
}

View file

@ -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{}

View file

@ -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())
}
}