-
Notifications
You must be signed in to change notification settings - Fork 21
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
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this 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
lib/mcp/server.rb
Outdated
def handle_rack_request(request) | ||
@transport.handle_request(request) | ||
end |
There was a problem hiding this comment.
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..
There was a problem hiding this comment.
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 😄
There was a problem hiding this comment.
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 🔁
ruby-sdk/examples/sse_test_server.rb
Line 67 in 78061f7
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.
There was a problem hiding this comment.
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.........
I also thought a bit about a possible interface... and my idea so far was
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
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 |
I like what you've proposed for the notifications. I can look to add that to my PR next week.
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).
💯 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). |
Added 3 new commits:
@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. |
Co-authored-by: Topher Bullock <topher.bullock@shopify.com>
Co-authored-by: Kevin Fischer <kfischer_okarin@yahoo.co.jp>
Latest set of commits are just a rebase after some new conflicts.
|
Thanks very much appreciated :)
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") |
There was a problem hiding this comment.
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
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}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
puts "request: #{request.inspect}" |
this logs the whole Rack::Request
, which pollutes the logs
def handle_post(request) | ||
body_string = request.body.read | ||
session_id = extract_session_id(request) | ||
|
||
body = parse_request_body(body_string) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
rescue JSON::ParserError | |
rescue JSON::ParserError, TypeError |
rescue on TypeError
would address issues with nil request bodies.
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 changesnotify_prompts_list_changed()
- Send a notification when the prompts list changesnotify_resources_list_changed()
- Send a notification when the resources list changesCloses #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
Checklist
Local Tests