internal/mcp/network_client.go:219 matches a decoded frame to the in-flight call on id alone:
if !rpcIDMatches(message.ID, id)
There is no check that the frame is a response rather than a server-initiated request, and decodeResponse returns whatever JSON object the body holds. So a streamable-HTTP server that answers a tools/call POST with {"jsonrpc":"2.0","id":1,"method":"roots/list","params":{}} produces err = nil with an empty result: the caller sees a tool that returned nothing rather than a protocol error.
Found while closing #924, which fixed the same class in the stdio dispatcher via isRequestOrNotification() in readLoop (7c50a9f). Filing separately because the severity is different and the stdio issue should not stay open on it.
Scope, so nobody over-reads this:
- It is not misdelivery.
networkClient.request takes client.mu and holds it across the whole POST (network_client.go:200-201), so there is exactly one call in flight and the body is that call's reply. Nothing lands on the wrong caller.
- The worst outcome is a silently empty result where an error would be correct, which is a diagnosability problem rather than a correctness or security one.
- Both SSE paths already have the guard:
deliverEventMessage (network_client.go:542) and decodeSSERPCMessage (:639). This is the plain JSON body path only.
Fix: reuse the same isRequestOrNotification() predicate before the id match and return a protocol error naming the unexpected method. A regression driving an httptest server that answers a tools/call POST with a method-bearing frame would pin it; the shape of TestStdioClientIgnoresServerInitiatedRequestsInPendingResponses transfers directly.
internal/mcp/network_client.go:219matches a decoded frame to the in-flight call on id alone:There is no check that the frame is a response rather than a server-initiated request, and
decodeResponsereturns whatever JSON object the body holds. So a streamable-HTTP server that answers atools/callPOST with{"jsonrpc":"2.0","id":1,"method":"roots/list","params":{}}produceserr = nilwith an empty result: the caller sees a tool that returned nothing rather than a protocol error.Found while closing #924, which fixed the same class in the stdio dispatcher via
isRequestOrNotification()inreadLoop(7c50a9f). Filing separately because the severity is different and the stdio issue should not stay open on it.Scope, so nobody over-reads this:
networkClient.requesttakesclient.muand holds it across the whole POST (network_client.go:200-201), so there is exactly one call in flight and the body is that call's reply. Nothing lands on the wrong caller.deliverEventMessage(network_client.go:542) anddecodeSSERPCMessage(:639). This is the plain JSON body path only.Fix: reuse the same
isRequestOrNotification()predicate before the id match and return a protocol error naming the unexpected method. A regression driving an httptest server that answers atools/callPOST with a method-bearing frame would pin it; the shape ofTestStdioClientIgnoresServerInitiatedRequestsInPendingResponsestransfers directly.