Skip to content

Implement Streamable HTTP transport #33

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

Ginja
Copy link

@Ginja Ginja commented May 30, 2025

Implements a rack-compatible Streamable HTTP transport with SSE support. As well as add notification support for the server with the following methods:

  • notify_tools_list_changed() - Send a notification when the tools list changes
  • notify_prompts_list_changed() - Send a notification when the prompts list changes
  • notify_resources_list_changed() - Send a notification when the resources list changes

Closes #4

Motivation and Context

To fulfill the Streamable HTTP transport specification as described here.

How Has This Been Tested?

Local tests only (see examples/). Working on implementing it with my MCP server that's a WIP.

Breaking Changes

Should introduce no breaking changes as using the StreamableHTTP transport is optional

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Local Tests

Started with run options --seed 59936

  132/132: [============================================================================================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.21315s
132 tests, 405 assertions, 0 failures, 0 errors, 0 skips

@Ginja Ginja changed the title Implement StreamableHTTP transport Implement Streamable HTTP transport May 30, 2025
Copy link
Contributor

@kfischer-okarin kfischer-okarin left a comment

Choose a reason for hiding this comment

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

I think it might be easier to develop and review if updating the server and transport interfaces to support notifications - including implementing the necessary methods for STDIO was done as a first separate step/PR

Then adding another PR with the Transport implementation + examples for Streamable HTTP would be easier to do and review...

Also about the current approach - I think this should be better marked as a StandaloneHTTPTransport - since I'm not sure that could be integrated into a Rails/Sinatra/Hanami/etc app as is.....

But I think the proper road ahead for those frameworks would be maybe to offer separate mcp-rails-transport mcp-sinatra-transport etc addon gems which provide their specific Transport implementation and the respective frameworks idiomatic Plugin boilerplate

Comment on lines 97 to 102
def handle_rack_request(request)
@transport.handle_request(request)
end
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be better if the server interface would not contain methods that only make sense for a certain family of transports..

Copy link
Author

@Ginja Ginja May 31, 2025

Choose a reason for hiding this comment

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

I can see that POV. No strong opinions over here. I don't think it looks particularly ugly to be able to do this:

response = server.handle_rack_request(request)

However, if there's consensus I can drop this method so that the above would look like this

response = transport.handle_request(request)

Similar to how stdio uses transport.open.

Or we modify the current handle method in the server that call the right method based on the transport.

    def handle(request)
      case transport_type
      when :stdio
        JsonRpcHandler.handle(request) do |method|
          handle_request(request, method)
        end
      when :streamablehttp
        @transport.handle_request(request)
      else
        raise "Unknown transport type: #{transport_type}"
      end
    end

Then everything becomes

response = server.handle(request)

Open to other ideas/suggestions as well 😄

Copy link
Contributor

Choose a reason for hiding this comment

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

Server should be kept as unaware of Transport concerns as possible.

What concerns me more than the intro of handle_rack_request to server is the indirection that this introduces between Server and Transport

Server having a transport attribute and Transport having a server attribute feels like a step in the wrong direction and leads to this 🔁

server.transport = MCP::Transports::HTTP.new(server)

Ideally we should keep handle_request isolated to the transport so that a rack-specific transport's handle_request is effectively "handle rack request"

All of this behaviour can be removed and the example can be updated to handle requests on the transport.

Copy link
Contributor

@kfischer-okarin kfischer-okarin Jun 1, 2025

Choose a reason for hiding this comment

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

Server having a transport attribute and Transport having a server attribute feels like a step in the wrong direction and leads to this 🔁

I see that and am bothered by that too - even after proposing my interface suggestion - and I'm not sure I have a good clean alternative yet..... 🤔 (Maybe I'm overlooking an obvious alternative)

My design is based on the assumption that it would be best to add those notify_... methods (which by their event driven nature should be able to be triggered from outside the normal request/response cycle started by Transport#open) to the Server interface, since it's the one object knowing about the actual Protocol right now... and keeping that knowledge/concern inside one class seemed like a cleaner design.

But of course, one could depart from that assumption and instead provide a second class for that job - like a ServerMessageSender which has a reference to the Transport internally to do its job...

But even if we do that - I think eventually the same issue would occur again once the SDK tries to support Sampling... at which point the tool that needs sampling to do its job will need a reference to something that knows the Transport in order to send the sampling request to the client - which is the same loop again.........

@kfischer-okarin
Copy link
Contributor

kfischer-okarin commented May 31, 2025

I also thought a bit about a possible interface... and my idea so far was

  1. add a method like this to the Server
def support_notifications(transport, tools_list_changed: false, resources_list_changed: false, resources_subscribe: false, prompts_list_changed: false, logging: false)

which would be called by the server implementer depending on the needs/capabilities of the application

  1. Add a send_notification or maybe more generic send_to_client to the transport (since I think all transports use the same mechanism for notifications and server send requests) - which in case of STDIO would just send to stdout

  2. Add all protocol methods as public methods to the server which uses the transport internally to just send it

def notify_tools_list_changed
  @transport.send_to_client(method: Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED)
end

def notify_resources_list_changed
  # ...

def notify_resource_changed(uri)
  # ...

def notify_prompts_list_changed
  # ...

def log_to_client(data, level:, logger: nil)
  # ...

Sending those notifications will be the server implementer's responsibility anyways so adding them easy to use to the server interface seems best....

I was planning to send a PoC Pull Request sooner or later when I got time... but if you like I could make one quite soon (maybe during the weekend) so it's maybe easier to talk about

@Ginja
Copy link
Author

Ginja commented May 31, 2025

I like what you've proposed for the notifications. I can look to add that to my PR next week.

Also about the current approach - I think this should be better marked as a StandaloneHTTPTransport - since I'm not sure that could be integrated into a Rails/Sinatra/Hanami/etc app as is.....

I prefer not to rename this. This is providing a barebone implementation of the StreamableHTTP transport. I think it's best to leave it up to implementers as to how they want to expose their MCP servers. Either directly with Rack & a web server (e.g. Puma, Unicorn, WEBrick, etc...) or contained within a web framework (e.g. Rails, Sinatra, etc...).

I prefer to keep this gem free of strong opinions (e.g. no nicities provided for wrapping this in a Rails controller, or including default/expected routes).

But I think the proper road ahead for those frameworks would be maybe to offer separate mcp-rails-transport mcp-sinatra-transport etc addon gems which provide their specific Transport implementation and the respective frameworks idiomatic Plugin boilerplate

💯 this. It should not be this gem's concern to facilitate the smooth integration into web frameworks. We can however, provide examples in the documentation (which could refer to the potential framework-specific gems).

@Ginja Ginja force-pushed the streamable-http branch from 78061f7 to fb2b06b Compare June 2, 2025 21:00
@Ginja
Copy link
Author

Ginja commented Jun 2, 2025

Added 3 new commits:

  • Converted rack to a development dependency (00fb685)
  • Removed transport-specific code from server (44a35b2)
  • Added initial notification support based on @kfischer-okarin's suggestions (fb2b06b)

@kfischer-okarin I added you as a co-author, I hope you don't mind. I figured it was the right thing to do as you proposed the design ❤️ If you prefer I can drop this commit and this support can be added in a dedicated PR. One method I skipped adding was this:

def support_notifications(transport, tools_list_changed: false, resources_list_changed: false, resources_subscribe: false, prompts_list_changed: false, logging: false)

I wasn't completely sure what the implementation looked like. I figured this can be added later.

@Ginja Ginja force-pushed the streamable-http branch from a8785de to bde7290 Compare June 4, 2025 05:41
@Ginja
Copy link
Author

Ginja commented Jun 4, 2025

Latest set of commits are just a rebase after some new conflicts.

Started with run options --seed 28896

  132/132: [===========================================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.22367s
132 tests, 405 assertions, 0 failures, 0 errors, 0 skips
$ bundle exec rubocop
Inspecting 49 files
.................................................

49 files inspected, no offenses detected

@kfischer-okarin
Copy link
Contributor

@Ginja

I added you as a co-author, I hope you don't mind. I figured it was the right thing to do as you proposed the design ❤️

Thanks very much appreciated :)

One method I skipped adding was this:

def support_notifications(transport, tools_list_changed: false, resources_list_changed: false, resources_subscribe: false, prompts_list_changed: false, logging: false)

That method is basicallt an explicit expression of the fact that notifications are optional, and since the Object doesn't need a Transport reference unless it supports notifications, I thought it would be ok if we only add it here and not in the constructor.

As for the flags - those would play into determining the capabilities hash

I submitted a little Capabilities related refactor as #42 - if that is adopted.... adding the respective capabilities dynamically depending on which flag is specified should be easy along the lines of

@capabilities.support_tools_list_changed if tools_list_changed
#...

@@ -29,5 +29,7 @@ Gem::Specification.new do |spec|

spec.add_dependency("json_rpc_handler", "~> 0.1")
spec.add_development_dependency("activesupport")
spec.add_development_dependency("puma", ">= 5.0.0")
spec.add_development_dependency("rack", ">= 2.0.0")
Copy link
Contributor

Choose a reason for hiding this comment

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

running ruby examples/http_server.rb requires adding rackup as a dev dependency

Suggested change
spec.add_development_dependency("rack", ">= 2.0.0")
spec.add_development_dependency("rack", ">= 2.0.0")
spec.add_development_dependency("rackup", ">= 2.1.0")

end

# Handle the request
puts "request: #{request.inspect}"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
puts "request: #{request.inspect}"

this logs the whole Rack::Request, which pollutes the logs

Comment on lines +102 to +106
def handle_post(request)
body_string = request.body.read
session_id = extract_session_id(request)

body = parse_request_body(body_string)
Copy link
Contributor

Choose a reason for hiding this comment

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

body_string can be nil here, POST requests with an empty request body ( from testing via the MCP inspector ) raised in the http example:

TypeError: no implicit conversion of nil into String (TypeError)

    Parser.parse(source, opts)
                 ^^^^^^^^^^^^
	/opt/homebrew/lib/ruby/gems/3.4.0/gems/json-2.12.2/lib/json/common.rb:338:in 'JSON::Ext::Parser.parse'
	/opt/homebrew/lib/ruby/gems/3.4.0/gems/json-2.12.2/lib/json/common.rb:338:in 'JSON.parse'
	examples/http_server.rb:131:in 'block in <main>'

parse_request_body tries JSON.parse(nil) and throws an exception; this should be handled as an invalid request similar to invalid json string


def parse_request_body(body_string)
JSON.parse(body_string)
rescue JSON::ParserError
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
rescue JSON::ParserError
rescue JSON::ParserError, TypeError

rescue on TypeError would address issues with nil request bodies.

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.

support the streamable part of Streamable HTTP
4 participants