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

Merged
merged 12 commits into from
Jun 13, 2025

Conversation

Ginja
Copy link
Contributor

@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 107
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
Contributor 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
Contributor 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
Contributor 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
Contributor 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
#...

@Ginja Ginja force-pushed the streamable-http branch from bde7290 to 91af9ad Compare June 9, 2025 21:15
topherbullock
topherbullock previously approved these changes Jun 10, 2025
Copy link
Contributor

@topherbullock topherbullock left a comment

Choose a reason for hiding this comment

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

thanks for this @Ginja!

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.

Sorry for not reviewing and commenting on this earlier - I was mainly concerned with the Server class and Transport changes and did not look at tests or the examples.

But in my opinion the both tests and examples still have some problems which I would prefer not to be merged (but of course I can understand if the main maintainers want to move forward and fix these issues later - so feel free to dismiss as just one contributors opinion)

I gave a few representative examples of the issues I personally see

No offense intended, but my general impression while reading was that examples and tests have maybe been generated by an coding agent (which I totally support) but not deeply reviewed
(this again is of course something which the main maintainer team might have a different opinion on - i.e. it's fine to merge code like that because with Coding Agents it's also as easy to change it again later... I personally don't think that is a good approach but of course respect the maintainers direction)

Comment on lines +61 to +63
#### Notification Format

Notifications follow the JSON-RPC 2.0 specification and use these method names:
Copy link
Contributor

Choose a reason for hiding this comment

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

This section seems unnecessarily specific and redundant about JSON-RPC - this is mentioned in the main section already

Comment on lines +162 to +164
puts "The server will return a session ID in the Mcp-Session-Id header."
puts "Use this session ID for subsequent requests."
puts ""
Copy link
Contributor

Choose a reason for hiding this comment

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

This mentions the Mcp-Session-Id header and it is somewhat (I think not correctly though) supported in the client - but the server does not actually create or emit any such headers...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

but the server does not actually create or emit any such headers...

Unless I'm misunderstanding, I believe it does?

$ curl -D - -s -o /dev/null -X POST http://localhost:9292/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: e286fd80-59b8-4281-9bdd-39e88d4d2f26
Content-Length: 184

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm sorry 🙈 it seems I missed checking the Transport Implementation itself - I only looked in the respective example servers. Yes it's implemented there 👍🏻

@kfischer-okarin
Copy link
Contributor

kfischer-okarin commented Jun 11, 2025

Also as an additional note:

I'm half-way finished working on a little minimal version containing a subset of this branch that implements the general list changed notifications support for STDIO - (of course properly attributing @Ginja as Co-Author everywhere where I used any code from this PR) - since this PR seems to get quite big and if the discussion/review goes on it might still take a while until any value is provided to the main branch...

Unless you just want to go on with this branch as is which is also fine of course ;)

Comment on lines 4 to 44
class Transport
# Initialize the transport with the server instance
def initialize(server)
@server = server
end

# Send a response to the client
def send_response(response)
raise NotImplementedError, "Subclasses must implement send_response"
end

# Open the transport connection
def open
raise NotImplementedError, "Subclasses must implement open"
end

# Close the transport connection
def close
raise NotImplementedError, "Subclasses must implement close"
end

private
# Handle a JSON request
# Returns a response that should be sent back to the client
def handle_json_request(request)
response = @server.handle_json(request)
send_response(response) if response
end

# Handle an incoming request
# Returns a response that should be sent back to the client
def handle_request(request)
response = @server.handle(request)
send_response(response) if response
end

def handle_json_request(request)
response = @server.handle_json(request)
send_response(response) if response
# Send a notification to the client
# Returns true if the notification was sent successfully
def send_notification(method, params = nil)
raise NotImplementedError, "Subclasses must implement send_notification"
end
end
Copy link
Contributor

@kfischer-okarin kfischer-okarin Jun 12, 2025

Choose a reason for hiding this comment

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

This is a rather fuzzy big picture design feedback - but I was wondering what to include in the Transport interface and what not...

The main goal of the Transport interface from my point of view is hiding the details of the transport method from the client code.

Clients of the Transport object so far

The main client until now was the main application entrypoint and the only really essential public interface methods were open and close. Everything else can be easily handled inside the loop created by open - so technically there is no need for any of the other methods to be public.

Now with the current addition of the notify methods the server becomes an additional client. And the only thing the server needs to know is how to send a notification via the transport. So send_notification is another must-have addition to the interface.

Issues surfaced through adding the Streamable HTTP implementation

What about the Rack Setup?

If we wanted to keep the pure simple open interface - it would be necessary to somehow include all the necessary Rack Setup in the current Transport class.

But considering the wider point of view that the natural Rack interface would be just exposing a callable taking a rack env object - maybe the open interface isn't the best way to handle things after all?

Make the Transport interface super simple?

So maybe removing open (and accordingly close I guess) from the interface and just have each Transport document their own preferred way of starting itself might be another valid way to do things - so the only method included in the public interface is really send_notificaton

But even in that case I'd still personally say that it would be nicer to make the Streamable HTTP a bit more out-of-the back Rack startable than it is now... which probably just means:

  • exposing a way to add middlewares
  • adding an option for the endpoint name (/mcp by default)
  • ...?

Copy link
Contributor

Choose a reason for hiding this comment

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

make the Streamable HTTP a bit more out-of-the back Rack startable than it is now

yeah, I think the next direction after this merges should be to allow base StreamableHTTP transport to be easily dropped into any Rack, Rails, etc. app - build out conventions that make adding to any existing server trivial.

this gives us a base we can work from and a reference implementation, so I'm keen to get this merged and iterate forward

Copy link
Contributor

Choose a reason for hiding this comment

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

@kfischer-okarin let's continue the drop-in rack chat here #59.
I've made a separate one for Rails #60

Copy link
Contributor

@topherbullock topherbullock left a comment

Choose a reason for hiding this comment

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

thanks for the clean up @Ginja. This looks good to merge.
gives us a reference for future improvements that make adding http transport to any rack/rails app trivial.

we can fix forward with any tweaks / extensions

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.

Yes I guess merging it as is and then continuing improvements in further smaller PRs is maybe the better way to go otherwise this PR will be a blocker too long 👍🏻

@topherbullock topherbullock merged commit 382ae13 into modelcontextprotocol:main Jun 13, 2025
5 checks passed
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