Skip to content

Fix response result deserialization to use the original request method (#601) - #999

Open
remote-controlled-man wants to merge 1 commit into
modelcontextprotocol:mainfrom
remote-controlled-man:fix/kotlin-sdk-601-request-result-deserialization
Open

remote-controlled-man wants to merge 1 commit into
modelcontextprotocol:mainfrom
remote-controlled-man:fix/kotlin-sdk-601-request-result-deserialization

Conversation

@remote-controlled-man

Copy link
Copy Markdown

Closes #601.

What

Response results were deserialized by JSON shape (RequestResultPolymorphicSerializer) before the response was correlated with its original request. A response whose payload resembled another result type could be decoded as the wrong runtime type and then fail at the erased-generic completion cast with ClassCastException (e.g. tasks/result vs tasks/get payloads with overlapping shapes). The Streamable HTTP SSE replay path additionally dropped the raw result JSON via msg.copy(id = …), making recovery impossible there.

How

  • Capture the raw result JSON during wire decode: JSONRPCResponse.rawResult is internal and @Transient, set at the single polymorphic funnel (JSONRPCMessagePolymorphicSerializer's response branch) that every transport decodes through.
  • At request/response correlation, select the result deserializer from the original request method (all 21 request methods mapped) and decode the raw JSON to that method's declared runtime result type before completing the request. Custom/unknown methods keep the previous shape-based behavior, and programmatic responses without raw JSON are unaffected.
  • The residual erased cast remains only as the isolated, documented consequence of the public request<T> signature; the value being cast now already has the declared runtime type.
  • Streamable HTTP replay rewrites the replay id in the raw JSON before decoding (decodeIncomingMessage) instead of copying an already shape-decoded message, at both SSE sites.

Intentional behavior change

A response whose result matches no known shape now surfaces as GetTaskPayloadResult and reaches correlation, so a type-mismatched caller fails fast via completeExceptionally instead of the whole message decode failing and the request hanging until timeout.

Tests

  • ProtocolResultDeserializationTest (core, commonTest): on the base, the tasks/result/tasks/get overlapping-shape cases fail with ClassCastException and an unknown-shape payload fails the whole message decode (RED); after the fix all 7 pass (GREEN), with controls for ping, tools/call, task-augmented tools/call, and direct-consumer decode.
  • StreamableHttpClientTransportReplayTest (client, jvmTest): end-to-end replay through Protocol + StreamableHttpClientTransport + MockEngine inline SSE with a server-side id rewritten to the POST id — RED on base, GREEN after.
  • :kotlin-sdk-core:jvmTest 604/604, :kotlin-sdk-client:jvmTest 79/79, jsTest (core 535, client 66) and a JVM sweep of core/client/server/testing/umbrella all pass; jvmApiCheck, ktlintCheck, and detekt pass — the public API is unchanged.
  • Not run on this Windows host: full apiCheck/build/check (native/Apple targets) and conformance-test/TS integration suites (require npx); the 30 :integration-test:jvmTest failures are pre-existing environmental npx absence, unchanged from the base.

The correlation-based direction follows the maintainer's review guidance on #913; #717 and #734 explored earlier approaches. Developed with AI assistance; I have reviewed the change and take responsibility for it.

modelcontextprotocol#601)

An incoming JSON-RPC response's result was decoded by JSON shape alone, so
a response whose payload matches another type's shape (e.g. a tasks/result
payload shaped like CallToolResult) was completed with the wrong runtime
type and failed at the erased generic cast in Protocol.request.

- Capture the raw result JSON while decoding JSONRPCResponse on the wire
  path, keeping the public JSONRPCResponse API unchanged.
- At the request/response correlation point, decode the raw result with
  the deserializer declared by the original request's method, falling back
  to the shape-decoded result for custom methods, programmatically
  constructed responses, and task-augmented calls returning
  CreateTaskResult.
- Rewrite the SSE replay message id in the raw JSON before decoding in
  StreamableHttpClientTransport instead of copying the decoded message,
  so the raw result survives.
- Results whose shape matches no known type now surface as
  GetTaskPayloadResult (the raw-payload result type) instead of failing
  the whole message decode.
Copilot AI lite review requested due to automatic review settings September 18, 2026 06:44

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This branch has not been deployed

No deployments
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.

type-unsafe cast in Protocol result deserialization

2 participants