Skip to content

StreamableHttpPostTransport incorrectly disposes the SSE response stream when sending sampling/createMessage #464

Closed
@mkeeley-tricentis

Description

@mkeeley-tricentis

Describe the bug
The C# MCP server, when using streaming http transport, erroneously closes the SSE response stream too early. It happens when a Sampling request is made from within a Tool request; the sampling request has the same request id as the pending tool request, and the stream mistakes the sampling request for the response to the tool request.

To Reproduce
Steps to reproduce the behavior:

  1. Create a MCP server, http streaming, stateless = false, with a tool configured.
  2. The tool should make a sampling request back to the client
  3. From a client, supporting sampling, invoke the tool (the request id should be 1)
  4. After the tool makes the sampling request, the SSE response stream is disposed.
  5. The sampling client returns a sampling response to the tool, which returns it's final response to the client
  6. The final tool response is discarded, because the SSE response stream is already disposed.
  7. The client hangs, waiting for the tool response.

Expected behavior
The SSE response stream should only be closed when the final tool response is returned.

Logs
n/a

Additional context
I believe the bug is in StreamableHttpPostTransport:

if (message.Data is JsonRpcMessageWithId response && response.Id == _pendingRequest)

There is a message filter looking for a message with an id equal to the pending request id, in order to determine when to complete the response stream. This checks for messages of class JsonRpcMessageWithId - but both JsonRpcRequest and JsonRpcResponse are subclasses.
The sampling request is a JsonRpcRequest, and gets its id from an auto-incremented id in McpSession:
// Set request ID
if (request.Id.Id is null)
{
request = request.WithId(new RequestId(Interlocked.Increment(ref _lastRequestId)));
}

The bug happens if the incoming request id is 1 (typical) the outgoing sampling request also gets an id of 1 (or obviously, some other situation where the ids collide)

ergo: The request ids for the incoming and outgoing requests are being conflated. They are in separate domains, and must not be compared.

Note: the sampling extension methods don't set an id for the sampling request, which is why it ends up with 1:

return server.SendRequestAsync(
RequestMethods.SamplingCreateMessage,
request,
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
cancellationToken: cancellationToken);

RequestId requestId = default,

Possible Fix
Probably StopOnFinalResponseFilter() should check that the message is of type JsonRpcResponse rather than JsonRpcMessageWithId.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions