From 284b81270591764d259c204e791dab0f53a3e47a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 17 Oct 2026 03:19:42 +0900 Subject: [PATCH] Leave listChanged out of initialize when notifications cannot be delivered ## Motivation and Context `initialize` answers with the server's capabilities as configured, by default `listChanged: true` on tools, prompts and resources, whatever the transport can deliver. Stateless Streamable HTTP has no session streams, so `send_notification` reports non-delivery there and a `notifications/*/list_changed` never reaches a handshake client, yet `initialize` promised it (issue #619). The same server already leaves the flags out of `server/discover` when `subscriptions/listen` is not served, so the two lifecycles described it differently. The specification defines `listChanged` as whether the server will emit the notification when the list changes. `Transport#delivers_notifications?` now tells the server whether notifications reach a client outside a response (`true` by default, `false` for stateless Streamable HTTP), and `initialize` leaves `listChanged` and `subscribe` out when they do not, with the stripping `server/discover` already applies. The capability objects themselves stay: `prompts` and `resources` with nothing registered still answer their list methods, as the Python SDK advertises them too, and `logging` stays on both lifecycles, since `logging/setLevel` is served, although stateless Streamable HTTP drops a handshake client's log messages, as its documentation says. Each lifecycle keeps the flags its own delivery path can honor, so the two still differ where those paths do: a stateless transport serving `subscriptions/listen` keeps them in `server/discover`, since listen streams deliver the notifications there, but not in `initialize`, and a stateful one without it does the reverse. Fixes #619. ## How Has This Been Tested? `test/mcp/server_test.rb` (the flags are stripped when the transport delivers no notifications, and kept when it does or does not say) and `test/mcp/server/transports/streamable_http_transport_test.rb` (a stateless transport's `initialize` advertises no flags and, without `subscriptions/listen`, the same capabilities as its `server/discover`; a stateful one keeps them). Against the previous library the stripping tests fail on the present flags. The same files also cover a `Capabilities` builder, string keys, the configured capabilities staying unchanged, and the two configurations where only one lifecycle keeps the flags. ## Breaking Changes The `initialize` result of a stateless Streamable HTTP server carries no `listChanged` or `subscribe` flags. --- lib/mcp/server.rb | 15 +++- .../transports/streamable_http_transport.rb | 6 ++ lib/mcp/transport.rb | 8 ++ .../streamable_http_transport_test.rb | 78 +++++++++++++++++++ test/mcp/server_test.rb | 58 ++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index afb3e845..5d5eaa34 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1131,7 +1131,7 @@ def init(params, session: nil) { protocolVersion: negotiated_version, - capabilities: capabilities, + capabilities: handshake_capabilities, serverInfo: info, instructions: response_instructions, }.compact @@ -1208,6 +1208,19 @@ def discover(_request) def discover_capabilities return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen? + without_notification_flags(capabilities) + end + + # Capabilities as advertised by `initialize`. A transport that delivers no notifications to its sessions + # (stateless Streamable HTTP) cannot keep the promise the `listChanged` and `subscribe` flags make, so they are + # left out, as `server/discover` leaves them out when `subscriptions/listen` is not served. + def handshake_capabilities + return capabilities unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications? + + without_notification_flags(capabilities) + end + + def without_notification_flags(capabilities) capabilities.each_with_object({}) do |(name, value), stripped| stripped[name] = if value.is_a?(Hash) value.reject { |flag, _| ["listChanged", "subscribe"].include?(flag.to_s) } diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 2e2a825b..21c2d664 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -406,6 +406,12 @@ def serves_subscriptions_listen? @serve_subscriptions_listen end + # Stateless mode has no session streams to deliver notifications on (`send_notification` reports non-delivery), + # so `Server#init` advertises no `listChanged`/`subscribe` flags there. + def delivers_notifications? + !@stateless + end + def handle_request(request) rebinding_error = validate_dns_rebinding(request) return rebinding_error if rebinding_error diff --git a/lib/mcp/transport.rb b/lib/mcp/transport.rb index c48e3c9f..6e90f3f4 100644 --- a/lib/mcp/transport.rb +++ b/lib/mcp/transport.rb @@ -57,6 +57,14 @@ def serves_subscriptions_listen? false end + # Whether the transport delivers notifications to a connected client outside the response to one of its + # requests (`notifications/*/list_changed`, `notifications/resources/updated`). `Server#init` strips the + # `listChanged`/`subscribe` capability flags when it does not, since the handshake would promise notifications + # the transport drops. + def delivers_notifications? + true + end + private def generate_request_id diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 1fffa1db..7550435b 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -648,6 +648,84 @@ def string assert_equal 200, response[0] end + test "initialize on a stateless transport advertises no listChanged or subscribe flags" do + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true, serve_subscriptions_listen: false) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, + ) + + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + initialize_capabilities = JSON.parse(response[2][0]).dig("result", "capabilities") + assert_equal({ "tools" => {}, "prompts" => {}, "resources" => {}, "logging" => {} }, initialize_capabilities) + + # The same server describes itself the same way to a modern client. + discover = stateless_transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) + assert_equal initialize_capabilities, JSON.parse(discover[2][0]).dig("result", "capabilities") + ensure + stateless_transport.close + end + + test "initialize on a stateful transport keeps the listChanged flags" do + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, + ) + + response = @transport.handle_request(request) + + assert_equal 200, response[0] + assert_equal({ "listChanged" => true }, JSON.parse(response[2][0]).dig("result", "capabilities", "tools")) + end + + test "initialize on a stateless transport serving subscriptions/listen leaves the flags to server/discover" do + # Listen streams deliver the notifications in stateless mode too, so the modern lifecycle keeps the flags + # the handshake lifecycle cannot honor. + stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, + ) + + response = stateless_transport.handle_request(request) + + assert_equal 200, response[0] + assert_equal({}, JSON.parse(response[2][0]).dig("result", "capabilities", "tools")) + + discover = stateless_transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) + assert_equal({ "listChanged" => true }, JSON.parse(discover[2][0]).dig("result", "capabilities", "tools")) + ensure + stateless_transport.close + end + + test "initialize on a stateful transport without subscriptions/listen keeps the flags server/discover strips" do + transport = StreamableHTTPTransport.new(@server, serve_subscriptions_listen: false) + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "1", params: initialize_params }.to_json, + ) + + response = transport.handle_request(request) + + assert_equal 200, response[0] + assert_equal({ "listChanged" => true }, JSON.parse(response[2][0]).dig("result", "capabilities", "tools")) + + discover = transport.handle_request(modern_rack_request(modern_body("server/discover", {}))) + assert_equal({}, JSON.parse(discover[2][0]).dig("result", "capabilities", "tools")) + ensure + transport.close + end + test "allows server/discover POST without session ID in stateful mode" do # Per SEP-2575, `server/discover` is sessionless capability discovery and is exempt # from the session requirement, like `initialize`. diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index c1ac383f..50722462 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -164,6 +164,64 @@ class ServerTest < ActiveSupport::TestCase assert_equal({ tools: { listChanged: true } }, result[:capabilities]) end + test "#handle initialize strips listChanged and subscribe flags on a transport that delivers no notifications" do + # A stateless transport drops every notification, so the handshake must not promise them, as `server/discover` + # already does not when `subscriptions/listen` is not served. + server = Server.new(name: "handshake_test", capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + logging: {}, + }) + transport = mock + transport.stubs(:delivers_notifications?).returns(false) + server.transport = transport + + result = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params })[:result] + + assert_equal({ tools: {}, resources: {}, logging: {} }, result[:capabilities]) + end + + test "#handle initialize keeps the flags on a transport that delivers notifications or does not say" do + capabilities = { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } } + delivering = mock + delivering.stubs(:delivers_notifications?).returns(true) + + [delivering, mock].each do |transport| + server = Server.new(name: "handshake_test", capabilities: capabilities) + server.transport = transport + + result = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params })[:result] + + assert_equal capabilities, result[:capabilities] + end + end + + test "#handle initialize strips the flags of a Capabilities builder without changing the configured capabilities" do + capabilities = Server::Capabilities.new + capabilities.support_tools_list_changed + capabilities.support_resources_subscribe + server = Server.new(name: "handshake_test", capabilities: capabilities) + transport = mock + transport.stubs(:delivers_notifications?).returns(false) + server.transport = transport + + result = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params })[:result] + + assert_equal({ tools: {}, resources: {} }, result[:capabilities]) + assert_equal({ tools: { listChanged: true }, resources: { subscribe: true } }, server.capabilities) + end + + test "#handle initialize strips string-keyed flags on a transport that delivers no notifications" do + server = Server.new(name: "handshake_test", capabilities: { "tools" => { "listChanged" => true }, "logging" => {} }) + transport = mock + transport.stubs(:delivers_notifications?).returns(false) + server.transport = transport + + result = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params })[:result] + + assert_equal({ "tools" => {}, "logging" => {} }, result[:capabilities]) + end + test "#handle server/discover responds before initialize and regardless of capabilities" do # Per SEP-2575, discovery is sessionless: no prior `initialize`, no capability gate. server = Server.new(name: "discover_test", capabilities: {})