Skip to content

Conversation

@Kevin-K
Copy link
Contributor

@Kevin-K Kevin-K commented May 24, 2025

Solves #40.

Adds IO messaging per client id for SSE implementation.

Changes:

  • Adds client_id to the endpoint response during SSE initialization.
  • swaps from messaging all sse clients to specific client based on the endpoint client_id request.
  • Locally tested against MCP Inspector & Cursor.

Notes:
I couldn't find a specific impl for 2024-11-05 spec on SSE client session tracking. Rather it calls out that the client should respect the endpoint provided by the server.

  1. I've chosen to pass the client_id back to the client as the query parameter client_id in the endpoint for communication.
  2. When receiving a POST to the endpoint w/ client_id query param the server knows which SSE IO to respond on.
  3. Broadcast still functions as previously implemented to all clients (i.e. notifications on tool updates).
  4. See 2024-11-05 spec on HTTP SSE.
  5. In 2025-03-26, http streaming appears to use header based approach for session ID tracking.

Comment on lines -304 to -334
# Handle reconnection
if client_id && @sse_clients.key?(client_id)
handle_client_reconnection(client_id, browser_type)
else
Copy link
Contributor Author

@Kevin-K Kevin-K May 26, 2025

Choose a reason for hiding this comment

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

I moved this behavior from extract_client_id into the register_sse_client so that we can use extract_client_id on the /sse/messages path of request handling (to determine which client is requesting).

The logic fit succinctly within register_sse_client so I removed handle_client_reconnection from being its own method.

Comment on lines 402 to 405
params = []
params << query_string if query_string && !query_string.empty?
params << "client_id=#{client_id}"
endpoint += "?#{params.join('&')}" unless params.empty?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is primary change of this pr, where all other changes are consuming its changes.

  • The endpoint returned to the client now has a client_id in the query param. The client is supposed to respect the endpoint we give it.
  • Now when the client requests in through /sse/messages we can extract the client_id from the request parameter.

Copy link
Owner

Choose a reason for hiding this comment

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

brilliant 💟

@Kevin-K Kevin-K marked this pull request as ready for review May 27, 2025 00:19
@Kevin-K
Copy link
Contributor Author

Kevin-K commented May 27, 2025

Tests are passing and testing locally appears stable for me but given there isn't a strict client id protocol for the 2024-11-05 spec (just guidance that clients respect the endpoint) I think this would warrant some additional manual verification.

Client reconnections are something I'm not too well versed in with MCP, I was using npx @modelcontextprotocol/inspector for both the latest of main and this branch and was seeing matching issues of connection instability when running 2 connections through the inspector simultaniously.

end
end.to_h

headers['client_id'] = extract_client_id(request.env)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not necessarily a http header, but after we extract http headers I chose to extract the client ID and place it in the header data as well.

Copy link
Owner

@yjacquin yjacquin left a comment

Choose a reason for hiding this comment

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

Great PR, thanks !

I'm releasing 1.5.0 today with lots of code changes.

This could be part of the next release if you want :)

We need to solve the merge conflicts and we're good to go !

Just to keep in mind, the client_id behavior is now natively supported in the new revision with the StreamableHTTP transport (called session id in the other revision) but until then, your solution sounds perfect 👌

Comment on lines 402 to 405
params = []
params << query_string if query_string && !query_string.empty?
params << "client_id=#{client_id}"
endpoint += "?#{params.join('&')}" unless params.empty?
Copy link
Owner

Choose a reason for hiding this comment

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

brilliant 💟

@Kevin-K
Copy link
Contributor Author

Kevin-K commented Jun 2, 2025

Great PR, thanks !

I'm releasing 1.5.0 today with lots of code changes.

This could be part of the next release if you want :)

We need to solve the merge conflicts and we're good to go !

Just to keep in mind, the client_id behavior is now natively supported in the new revision with the StreamableHTTP transport (called session id in the other revision) but until then, your solution sounds perfect 👌

@yjacquin, I'm happy to resolve the conflicts and get this cleaned up on top of 1.5. Super excited to see it StreamableHttp coming out! I have some spare time this week I'll get this cleaned up.

@Kevin-K
Copy link
Contributor Author

Kevin-K commented Jun 18, 2025

@yjacquin this is ready for another set of eyes.

One thought I avoided putting in place just to not overdo the scope of the pr: The volume of calling methods with client_id constantly gets a little overwhelming. Refactoring the concept of a client into a class may remove the argument fatigue. Thinking of something like a Client class having send_result and send_error messages.

Not sure with your work on Streamable HTTP if you were seeing the same code pattern pop up, figured I'd note it for a code review :).

@mfoo
Copy link

mfoo commented Aug 19, 2025

Hello! When using the Python MCP SSE client I currently see some validation errors happening that I think this PR will fix.

Stack trace at the bottom, but in summary, the Python client performs schema validation and this project is sending JSONRPCResponse-typed objects with no id. id is a mandatory field as per this schema.json file (the 2025-x schemas are nearby).

This is also happening currently when we send JSONRPCError and JSONRPCResponse responses and you can see the required id field in the same schema file for both types.

Tangentially, there's also two more validation issues here:

  1. A JSONRPCNotification object where the method property is nil but required
  2. A JSONRPCError object where the type property is nil but required

It might be a good idea to generate a series of Ruby DTO objects from the schema.json for each protocol version and use them rather than crafting JSON directly.

Trace follows:

2025-08-19T11:01:00.656436Z [error    ] Error parsing server message   [mcp.client.sse] api_variant=local_dev langgraph_api_version=0.2.134 method=GET path=/assistants/{assistant_id}/schemas thread_name=MainThread
Traceback (most recent call last):                                                                                                       
  File "/home/martinfoot/.pyenv/versions/3.12.0/lib/python3.12/site-packages/mcp/client/sse.py", line 98, in sse_reader                  
    message = types.JSONRPCMessage.model_validate_json(  # noqa: E501                                                                    
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                    
  File "/home/martinfoot/.pyenv/versions/3.12.0/lib/python3.12/site-packages/pydantic/main.py", line 746, in model_validate_json         
    return cls.__pydantic_validator__.validate_json(                                                                                     
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                     
pydantic_core._pydantic_core.ValidationError: 9 validation errors for JSONRPCMessage                                                     
JSONRPCRequest.method                                                                                                                    
  Field required [type=missing, input_value={'jsonrpc': '2.0', 'id': None, 'result': {}}, input_type=dict]                               
    For further information visit https://errors.pydantic.dev/2.11/v/missing                                                             
JSONRPCRequest.id.int                                                                                                                    
  Input should be a valid integer [type=int_type, input_value=None, input_type=NoneType]                                                 
    For further information visit https://errors.pydantic.dev/2.11/v/int_type                                                            
JSONRPCRequest.id.str                                                                                                                    
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]                                               
    For further information visit https://errors.pydantic.dev/2.11/v/string_type                                                         
JSONRPCNotification.method                                                                                                               
  Field required [type=missing, input_value={'jsonrpc': '2.0', 'id': None, 'result': {}}, input_type=dict]                               
    For further information visit https://errors.pydantic.dev/2.11/v/missing                                                             
JSONRPCResponse.id.int                                                                                                                   
  Input should be a valid integer [type=int_type, input_value=None, input_type=NoneType]                                                 
    For further information visit https://errors.pydantic.dev/2.11/v/int_type                                                            
JSONRPCResponse.id.str                                                                                                                   
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]                                               
    For further information visit https://errors.pydantic.dev/2.11/v/string_type                                                         
JSONRPCError.id.str                                                                                                                      
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]                                               
    For further information visit https://errors.pydantic.dev/2.11/v/string_type                                                         
JSONRPCError.id.int                                                  
  Input should be a valid integer [type=int_type, input_value=None, input_type=NoneType]                                                  
    For further information visit https://errors.pydantic.dev/2.11/v/int_type                                                             
JSONRPCError.error                                                   
  Field required [type=missing, input_value={'jsonrpc': '2.0', 'id': None, 'result': {}}, input_type=dict]                                
    For further information visit https://errors.pydantic.dev/2.11/v/missing                                                              

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.

3 participants