Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1131,7 +1131,7 @@ def init(params, session: nil)

{
protocolVersion: negotiated_version,
capabilities: capabilities,
capabilities: handshake_capabilities,
serverInfo: info,
instructions: response_instructions,
}.compact
Expand Down Expand Up @@ -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) }
Expand Down
6 changes: 6 additions & 0 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions lib/mcp/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
58 changes: 58 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {})
Expand Down
Loading