Repository navigation
fix: keep logging levels per session #667
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -509,6 +509,20 @@ def close | |
| end | ||
| end | ||
|
|
||
| # Sessions a server-wide log broadcast is offered to, each applying its own level or the server default. | ||
| # Public only so that `Server#notify_log_message` can call it. Custom transports may implement this hook; | ||
| # returning `nil` keeps the server-wide filter. | ||
| # | ||
| # @api private | ||
| # @return [Array<ServerSession>] | ||
| def logging_sessions | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added short YARD comments to both hooks with |
||
| @mutex.synchronize do | ||
| @sessions.values.filter_map do |session| | ||
| session[:server_session] if session[:get_sse_stream] | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def send_notification(method, params = nil, session_id: nil, related_request_id: nil) | ||
| # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below: | ||
| # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "test_helper" | ||
| require "rack/mock" | ||
|
|
||
| module MCP | ||
| class Server | ||
| module Transports | ||
| class StreamableHTTPLoggingTest < ActiveSupport::TestCase | ||
| include InitializeParamsTestHelper | ||
|
|
||
| setup do | ||
| @server = Server.new(name: "test_server") | ||
| @transport = StreamableHTTPTransport.new(@server) | ||
| @transport.stubs(:start_keepalive_thread) | ||
| end | ||
|
|
||
| teardown do | ||
| @transport.close | ||
| end | ||
|
|
||
| test "broadcasts use each session's level before the application default" do | ||
| default_logging = LoggingMessageNotification.new(level: "warning") | ||
| @server.logging_message_notification = default_logging | ||
| debug_session, debug_stream = open_session | ||
| error_session, error_stream = open_session | ||
| _, default_stream = open_session | ||
|
|
||
| set_level(debug_session, "debug") | ||
| set_level(error_session, "error") | ||
| _, future_stream = open_session | ||
|
|
||
| ["debug", "info", "warning", "error"].each do |level| | ||
| @server.notify_log_message(data: { message: level }, level: level, logger: "ExampleLogger") | ||
| end | ||
|
|
||
| assert_equal ["debug", "info", "warning", "error"], log_levels(debug_stream) | ||
| assert_equal ["error"], log_levels(error_stream) | ||
| assert_equal ["warning", "error"], log_levels(default_stream) | ||
| assert_equal ["warning", "error"], log_levels(future_stream) | ||
| assert_equal "ExampleLogger", messages(debug_stream).first["params"]["logger"] | ||
| assert_same default_logging, @server.logging_message_notification | ||
| end | ||
|
|
||
| test "one session setting a level does not enable broadcasts to other sessions" do | ||
| session, stream = open_session | ||
| _, other_stream = open_session | ||
| set_level(session, "info") | ||
| _, future_stream = open_session | ||
|
|
||
| @server.notify_log_message(data: "debug detail", level: "debug") | ||
| @server.notify_log_message(data: "info detail", level: "info") | ||
|
|
||
| assert_equal ["info"], log_levels(stream) | ||
| assert_empty messages(other_stream) | ||
| assert_empty messages(future_stream) | ||
| assert_nil @server.logging_message_notification | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def request(method, payload: nil, session_id: nil) | ||
| env = Rack::MockRequest.env_for("http://localhost/", method: method) | ||
| env["HTTP_ACCEPT"] = "application/json, text/event-stream" | ||
| env["HTTP_MCP_PROTOCOL_VERSION"] = "2025-11-25" | ||
| env["HTTP_MCP_SESSION_ID"] = session_id if session_id | ||
| if payload | ||
| env["CONTENT_TYPE"] = "application/json" | ||
| env["rack.input"] = StringIO.new(payload.to_json) | ||
| end | ||
| @transport.call(env) | ||
| end | ||
|
|
||
| def open_session | ||
| status, headers, body = request("POST", payload: { | ||
| jsonrpc: "2.0", id: 1, method: "initialize", | ||
| params: initialize_params(protocolVersion: "2025-11-25"), | ||
| }) | ||
| assert_equal 200, status | ||
| assert_equal({}, JSON.parse(body.join).dig("result", "capabilities", "logging")) | ||
| session_id = headers.fetch("mcp-session-id") | ||
| status, = request("POST", session_id: session_id, payload: { | ||
| jsonrpc: "2.0", method: "notifications/initialized", | ||
| }) | ||
| assert_equal 202, status | ||
|
|
||
| status, headers, body = request("GET", session_id: session_id) | ||
| assert_equal 200, status | ||
| assert_equal "text/event-stream", headers["content-type"] | ||
| stream = StringIO.new | ||
| body.call(stream) | ||
| [session_id, stream] | ||
| end | ||
|
|
||
| def set_level(session_id, level) | ||
| status, headers, body = request("POST", session_id: session_id, payload: { | ||
| jsonrpc: "2.0", id: 2, method: "logging/setLevel", params: { level: level }, | ||
| }) | ||
| assert_equal 200, status | ||
| assert_equal "text/event-stream", headers["content-type"] | ||
| stream = StringIO.new | ||
| body.call(stream) | ||
| assert_equal({}, messages(stream).last["result"]) | ||
| end | ||
|
|
||
| def messages(stream) | ||
| stream.string.scan(/^data: (.+)$/).flatten.map { |json| JSON.parse(json) } | ||
| end | ||
|
|
||
| def log_levels(stream) | ||
| messages(stream).map { |message| message["params"]["level"] } | ||
| end | ||
| end | ||
| end | ||
| end | ||
| 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.
Keeping the level on the calling session is right. But
Server#notify_log_message(lines 448-450) filters only on the server-wide level, so after this change a client'slogging/setLevelno longer enables broadcasts at all, including on a single-session stdio server, which the PR now declares as a breaking change. That trades one deviation from the specification for another.Filtering the broadcast per recipient session avoids both: compare the notification's
levelwith that session's level and fall back to the server-wide default when the session set none. That is how the TypeScript and Python servers resolve the threshold, it keeps the existing stdio flow working, and the breaking-change section becomes unnecessary.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.
Broadcasts now enumerate the transports' existing recipients and use each
ServerSessionto filter and deliver the log. A client's level stays local, the application default remains the fallback, and stdio delivery keeps working. HTTP tests cover different client levels and both existing and future unconfigured sessions.