Skip to content

internal/jsonrpc2: send error response when result fails to marshal - #1400

Merged
guglielmo-san merged 3 commits into
modelcontextprotocol:mainfrom
beiryu:fix-1358-streamable-http-a-tool-result-that-fails
Oct 16, 2026
Merged

guglielmo-san merged 3 commits into
modelcontextprotocol:mainfrom
beiryu:fix-1358-streamable-http-a-tool-result-that-fails

Conversation

@beiryu

@beiryu beiryu commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

When a handler returned a result that json.Marshal could not encode (for example a +Inf or NaN float in StructuredContent), the connection recorded the write error but never answered the request. With the Streamable HTTP handler, the POST stayed open until the client timed out.

processResult now logs the internal error as before. It also writes a generic -32603 response with the message "internal error: result could not be encoded" for the same request ID. The waiting HTTP request is released and the client gets a JSON-RPC error. The error text does not include the encoding failure details.

Fixes #1358

Testing:

  • Added TestUnmarshalableResultSendsError in internal/jsonrpc2/conn_test.go. A handler returns math.Inf(1). The test checks that a -32603 response with the same ID is written and that the internal error is reported.
  • Added TestStreamableUnencodableToolResult in mcp/streamable_test.go. A tool returns +Inf in StructuredContent. For both Stateless: true and Stateless: false, the test checks that CallTool returns a JSON-RPC internal error instead of hanging.

I found that a tool result which cannot be encoded made the server hang with no answer. I wanted the client to get a clear error instead of waiting for its timeout.

Comment thread internal/jsonrpc2/conn.go
Comment on lines 716 to 735
response, respErr := NewResponse(req.ID, result, err)

// The caller could theoretically reuse the request's ID as soon as we've
// sent the response, so ensure that it is removed from the incoming map
// before sending.
c.updateInFlight(func(s *inFlightState) {
delete(s.incomingByID, req.ID)
})
if respErr == nil {
writeErr := c.write(notDone{req.ctx}, response)
if err == nil {
err = writeErr
}
} else {
err = c.internalErrorf("%#v returned a malformed result for %q: %w", from, req.Method, respErr)
// Still answer the call, so the peer isn't left waiting. Write
// failures are already recorded by c.write.
resp := &Response{ID: req.ID, Error: NewError(-32603, "internal error: result could not be encoded")}
_ = c.write(notDone{req.ctx}, resp)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c.internalError already wraps the error with ErrInternal, so i think it can be modified with:

	response, respErr := NewResponse(req.ID, result, err)
	if respErr != nil {
		err = c.internalErrorf("%#v returned a malformed result for %q: %w", from, req.Method, respErr)
		response, _ = NewResponse(req.ID, nil, err)
	}

	// The caller could theoretically reuse the request's ID as soon as we've
	// sent the response, so ensure that it is removed from the incoming map
	// before sending.
	c.updateInFlight(func(s *inFlightState) {
		delete(s.incomingByID, req.ID)
	})
	writeErr := c.write(notDone{req.ctx}, response)
	if err == nil {
		err = writeErr
	}

@beiryu

beiryu commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, thanks! I applied your suggestion: on a marshal failure we now build the error with c.internalErrorf (which already wraps ErrInternal), re-create the response from it via NewResponse, and send it through the normal write path. That removes the separate hand-built response branch. I also updated TestUnmarshalableResultSendsError to check for a -32603 error containing "malformed result" instead of the old fixed message.

@guglielmo-san
guglielmo-san merged commit 5dc802f into modelcontextprotocol:main Oct 16, 2026
9 checks passed
@guglielmo-san

Copy link
Copy Markdown
Contributor

@beiryu thank you for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streamable HTTP: a tool result that fails to marshal leaves the POST hanging (no JSON-RPC error)

2 participants