## Motivation and Context The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange, and two delivery requirements of that model were unimplemented: - Notifications a handler emits while serving a modern request (progress, log messages) had nowhere to go: the modern path answered with a single JSON object, and the ephemeral per-request session degraded every notification to non-delivery. The conformance requirement expects them as frames on the request's own response stream (`tools-call-with-progress` fails with "No progress notifications received" at 2026-07-28). - The envelope's `logLevel` member, which replaces the removed `logging/setLevel` RPC, was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement that no `notifications/message` is sent for requests that did not set `_meta` `logLevel`. The changes wire both up: - `StreamableHTTPTransport#handle_modern` registers a per-request notification sink for its ephemeral session; `send_notification` routes into the sink ahead of the stateless guard. A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response; a request that emitted none keeps the single JSON exchange and its HTTP status ladder. - `Server#handle_request` applies the lifted envelope's `logLevel` to the session via the existing `configure_logging`, and on modern-era sessions `ServerSession#notify_log_message` no longer falls back to the server-wide level: without a per-request `logLevel` it stays silent, on every transport. An unrecognized level reads as absent, the safe direction, matching the Python SDK's `allowed_log_levels`. The `server-stateless` scenario of the 2026-07-28 conformance requirements probes two stream-discipline rules through diagnostic fixture tools the Ruby fixture did not define, so both checks failed as not testable: `sep-2575-http-server-no-independent-requests-on-stream` calls `test_streaming_elicitation` and requires that every frame on the request's response stream is a notification or the final response, and `sep-2575-server-no-log-without-loglevel` calls `test_logging_tool` without an envelope `logLevel` and requires that no `notifications/message` reaches the stream. The fixture now defines both tools. `test_streaming_elicitation` reports progress and completes; `test_logging_tool` calls `notify_log_message` unconditionally and relies on the SDK's per-request gate, which drops the notification for modern requests that did not authorize logging through `io.modelcontextprotocol/logLevel`. Both lean on the notification streaming this change adds: with a `progressToken` or an authorizing `logLevel`, the notification is delivered as an SSE frame ahead of the final response rather than being dropped. One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs: delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved and the conformance checks are satisfied, but long-running handlers get no real-time interleaving. The sink is bounded (`MAX_MODERN_REQUEST_NOTIFICATIONS`, 1000): a handler emitting notifications in proportion to client-supplied input cannot grow one request's memory without bound, and past the cap the notify helpers report `false`, the same non-delivery degradation they have on every other undeliverable path. Live streaming can follow without changing the wire shape. ## How Has This Been Tested? `bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New tests cover: a modern `tools/call` streaming `notifications/progress` frames ahead of the result, the single JSON exchange staying in place without notifications, log suppression without the envelope `logLevel`, log delivery with it, the modern-era session ignoring the server-wide level while honoring a per-request one, and the notification cap bounding one request's buffered frames while the notify helpers report `false` past it. Against the conformance fixture server, the `tools-call-with-progress` progress check passes at `--spec-version 2026-07-28` (its remaining wire-schema check belongs to the separate `resultType` change), the stream-discipline checks `sep-2575-http-server-no-independent-requests-on-stream` and `sep-2575-server-no-log-without-loglevel` report SUCCESS, and manual probes confirm the positive directions the suite leaves untested: with `io.modelcontextprotocol/logLevel` in `_meta`, `test_logging_tool` streams its `notifications/message` frame ahead of the result, and with a `progressToken`, `test_streaming_elicitation` streams its `notifications/progress` frame. The `--requirements 2025-11-25` server leg passes 78/78, unchanged. ## Breaking Changes None for stable protocol versions: legacy requests and their notification delivery are unchanged. Within the modern lifecycle, notifications become deliverable where they were previously dropped, and log messages without a per-request `logLevel` stop leaking through the server-wide level, as the specification requires.
316 lines
13 KiB
Ruby
316 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class ServerNotificationTest < ActiveSupport::TestCase
|
|
include InstrumentationTestHelper
|
|
include DeprecationWarningTestHelper
|
|
|
|
class MockTransport < Transport
|
|
attr_reader :notifications
|
|
|
|
def initialize(server)
|
|
super
|
|
@notifications = []
|
|
end
|
|
|
|
def send_notification(method, params = nil)
|
|
@notifications << { method: method, params: params }
|
|
true
|
|
end
|
|
|
|
def send_response(response); end
|
|
def open; end
|
|
def close; end
|
|
def handle_request(request); end
|
|
end
|
|
|
|
setup do
|
|
configuration = MCP::Configuration.new
|
|
configuration.instrumentation_callback = instrumentation_helper.callback
|
|
|
|
@server = Server.new(
|
|
name: "test_server",
|
|
version: "1.0.0",
|
|
configuration: configuration,
|
|
)
|
|
|
|
@mock_transport = MockTransport.new(@server)
|
|
end
|
|
|
|
test "#notify_tools_list_changed sends notification through transport" do
|
|
@server.notify_tools_list_changed
|
|
|
|
assert_equal 1, @mock_transport.notifications.size
|
|
notification = @mock_transport.notifications.first
|
|
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification[:method]
|
|
assert_nil notification[:params]
|
|
end
|
|
|
|
test "#notify_prompts_list_changed sends notification through transport" do
|
|
@server.notify_prompts_list_changed
|
|
|
|
assert_equal 1, @mock_transport.notifications.size
|
|
notification = @mock_transport.notifications.first
|
|
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification[:method]
|
|
assert_nil notification[:params]
|
|
end
|
|
|
|
test "#notify_resources_list_changed sends notification through transport" do
|
|
@server.notify_resources_list_changed
|
|
|
|
assert_equal 1, @mock_transport.notifications.size
|
|
notification = @mock_transport.notifications.first
|
|
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notification[:method]
|
|
assert_nil notification[:params]
|
|
end
|
|
|
|
test "#notify_log_message sends notification through transport" do
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
|
|
assert_equal 1, @mock_transport.notifications.size
|
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method]
|
|
assert_equal({ "data" => { error: "Connection Failed" }, "level" => "error" }, @mock_transport.notifications.first[:params])
|
|
end
|
|
|
|
test "#notify_log_message does not warn when no protocol version has been established" do
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
|
|
assert_no_deprecation_warning do
|
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
end
|
|
|
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method]
|
|
end
|
|
|
|
test "#notify_log_message warns when configured protocol version is 2026-07-28" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
version: "1.0.0",
|
|
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
|
)
|
|
mock_transport = MockTransport.new(server)
|
|
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
|
|
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
|
server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
end
|
|
|
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method]
|
|
end
|
|
|
|
test "#notify_log_message warns when configured protocol version is 2026-07-28 without transport" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
version: "1.0.0",
|
|
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
|
)
|
|
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
|
|
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
|
server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
end
|
|
end
|
|
|
|
test "#notify_log_message warns when configured protocol version is 2026-07-28 below configured level" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
version: "1.0.0",
|
|
configuration: Configuration.new(protocol_version: "2026-07-28"),
|
|
)
|
|
mock_transport = MockTransport.new(server)
|
|
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
|
|
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
|
server.notify_log_message(data: { message: "test" }, level: "info")
|
|
end
|
|
|
|
assert_empty mock_transport.notifications
|
|
end
|
|
|
|
test "#notify_log_message does not warn when configured protocol version is older" do
|
|
server = Server.new(
|
|
name: "test_server",
|
|
version: "1.0.0",
|
|
configuration: Configuration.new(protocol_version: "2025-11-25"),
|
|
)
|
|
MockTransport.new(server)
|
|
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
|
|
assert_no_deprecation_warning do
|
|
server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
end
|
|
end
|
|
|
|
test "ServerSession#notify_log_message warns when negotiated protocol version is 2026-07-28 below configured level" do
|
|
server = Server.new(name: "test_server", version: "1.0.0")
|
|
mock_transport = MockTransport.new(server)
|
|
session = ServerSession.new(server: server, transport: mock_transport)
|
|
server.handle(
|
|
{
|
|
jsonrpc: "2.0",
|
|
method: "initialize",
|
|
id: 1,
|
|
params: {
|
|
protocolVersion: "2026-07-28",
|
|
capabilities: {},
|
|
clientInfo: { name: "test-client", version: "1.0" },
|
|
},
|
|
},
|
|
session: session,
|
|
)
|
|
session.configure_logging(MCP::LoggingMessageNotification.new(level: "error"))
|
|
|
|
assert_deprecation_warning(/MCP Logging .*2026-07-28/) do
|
|
session.notify_log_message(data: { message: "test" }, level: "info")
|
|
end
|
|
|
|
assert_empty mock_transport.notifications
|
|
end
|
|
|
|
test "ServerSession#notify_log_message on a modern-era session ignores the server-wide level" do
|
|
# SEP-2575: on the modern lifecycle, log delivery is authorized per request via
|
|
# the envelope's logLevel; the server-wide level MUST NOT apply.
|
|
server = Server.new(name: "test_server", version: "1.0.0")
|
|
mock_transport = MockTransport.new(server)
|
|
server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "debug")
|
|
session = ServerSession.new(server: server, transport: mock_transport, era: :modern)
|
|
|
|
session.notify_log_message(data: { message: "test" }, level: "error")
|
|
|
|
assert_empty mock_transport.notifications
|
|
end
|
|
|
|
test "ServerSession#notify_log_message on a modern-era session honors a per-request level" do
|
|
server = Server.new(name: "test_server", version: "1.0.0")
|
|
mock_transport = MockTransport.new(server)
|
|
session = ServerSession.new(server: server, transport: mock_transport, era: :modern)
|
|
session.configure_logging(MCP::LoggingMessageNotification.new(level: "info"))
|
|
|
|
session.notify_log_message(data: { message: "test" }, level: "error")
|
|
|
|
assert_equal 1, mock_transport.notifications.length
|
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, mock_transport.notifications.first[:method]
|
|
end
|
|
|
|
test "#notify_log_message sends notification with logger through transport" do
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger")
|
|
|
|
assert_equal 1, @mock_transport.notifications.size
|
|
notification = @mock_transport.notifications.first
|
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, notification[:method]
|
|
assert_equal({ "data" => { error: "Connection Failed" }, "level" => "error", "logger" => "DatabaseLogger" }, notification[:params])
|
|
end
|
|
|
|
test "#notify_log_message does not send notification with invalid log level" do
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
@server.notify_log_message(data: { message: "test" }, level: "invalid")
|
|
|
|
assert_equal 0, @mock_transport.notifications.size
|
|
end
|
|
|
|
test "#notify_log_message does not send notification when level is below configured level" do
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
@server.notify_log_message(data: { message: "test" }, level: "info")
|
|
|
|
assert_equal 0, @mock_transport.notifications.size
|
|
end
|
|
|
|
test "#notify_log_message sends notification when level is above configured level through transport" do
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
@server.notify_log_message(data: { message: "test" }, level: "critical")
|
|
|
|
assert_equal 1, @mock_transport.notifications.size
|
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications[0][:method]
|
|
assert_equal({ "data" => { message: "test" }, "level" => "critical" }, @mock_transport.notifications[0][:params])
|
|
end
|
|
|
|
test "notification methods work without transport" do
|
|
server_without_transport = Server.new(name: "test_server")
|
|
server_without_transport.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
|
|
# Should not raise any errors
|
|
assert_nothing_raised do
|
|
server_without_transport.notify_tools_list_changed
|
|
server_without_transport.notify_prompts_list_changed
|
|
server_without_transport.notify_resources_list_changed
|
|
server_without_transport.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
end
|
|
end
|
|
|
|
test "notification methods handle transport errors gracefully" do
|
|
# Replace server's transport with one that raises on send_notification.
|
|
Class.new(MockTransport) do
|
|
def send_notification(method, params = nil)
|
|
raise StandardError, "Transport error"
|
|
end
|
|
end.new(@server)
|
|
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
|
|
# Mock the exception reporter
|
|
expected_contexts = [
|
|
{ notification: "tools_list_changed" },
|
|
{ notification: "prompts_list_changed" },
|
|
{ notification: "resources_list_changed" },
|
|
{ notification: "log_message" },
|
|
]
|
|
|
|
call_count = 0
|
|
@server.configuration.exception_reporter.expects(:call).times(4).with do |exception, context|
|
|
assert_kind_of StandardError, exception
|
|
assert_equal "Transport error", exception.message
|
|
assert_includes expected_contexts, context
|
|
call_count += 1
|
|
true
|
|
end
|
|
|
|
# Should not raise errors to the caller
|
|
assert_nothing_raised do
|
|
@server.notify_tools_list_changed
|
|
@server.notify_prompts_list_changed
|
|
@server.notify_resources_list_changed
|
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
end
|
|
|
|
assert_equal 4, call_count
|
|
end
|
|
|
|
test "multiple notification methods can be called in sequence" do
|
|
@server.notify_tools_list_changed
|
|
@server.notify_prompts_list_changed
|
|
@server.notify_resources_list_changed
|
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
|
|
|
assert_equal 4, @mock_transport.notifications.size
|
|
|
|
notifications = @mock_transport.notifications
|
|
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method]
|
|
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method]
|
|
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method]
|
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method]
|
|
end
|
|
|
|
test "server.notify_log_message works after logging/setLevel via session" do
|
|
session = ServerSession.new(server: @server, transport: @mock_transport)
|
|
|
|
# Client sends logging/setLevel through session.
|
|
@server.handle(
|
|
{ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } },
|
|
session: session,
|
|
)
|
|
|
|
# Server-level broadcast should still work because logging level
|
|
# is stored on both the session and the server.
|
|
@server.notify_log_message(data: "broadcast log", level: "info")
|
|
|
|
log_notifications = @mock_transport.notifications.select { |n| n[:method] == Methods::NOTIFICATIONS_MESSAGE }
|
|
assert_equal 1, log_notifications.size
|
|
assert_equal "broadcast log", log_notifications.first[:params]["data"]
|
|
end
|
|
end
|
|
end
|