Skip to content

Commit bdf060f

Browse files
committed
fix: keep logging levels per session
1 parent 48c0cc9 commit bdf060f

6 files changed

Lines changed: 294 additions & 5 deletions

File tree

‎lib/mcp/server.rb‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,13 @@ def notify_resources_list_changed
447447
# Use stderr or OpenTelemetry instead.
448448
def notify_log_message(data:, level:, logger: nil)
449449
return unless @transport
450+
451+
if @transport.respond_to?(:logging_sessions) && (sessions = @transport.logging_sessions)
452+
sessions.each do |session|
453+
session.notify_log_message(data: data, level: level, logger: logger)
454+
end
455+
return
456+
end
450457
return unless logging_message_notification&.should_notify?(level)
451458

452459
params = { "data" => data, "level" => level }
@@ -1382,8 +1389,11 @@ def configure_logging_level(request, session: nil)
13821389
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
13831390
end
13841391

1385-
session&.configure_logging(logging_message_notification)
1386-
@logging_message_notification = logging_message_notification
1392+
if session
1393+
session.configure_logging(logging_message_notification)
1394+
else
1395+
@logging_message_notification = logging_message_notification
1396+
end
13871397

13881398
{}
13891399
end

‎lib/mcp/server/transports/stdio_transport.rb‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ def internal_error_response(parsed)
9696
response
9797
end
9898

99+
# Sessions a server-wide log broadcast is offered to, each applying its own level or the server default.
100+
# Public only so that `Server#notify_log_message` can call it. Custom transports may implement this hook;
101+
# returning `nil` keeps the server-wide filter.
102+
#
103+
# @api private
104+
# @return [Array<ServerSession>, nil]
105+
def logging_sessions
106+
return unless @session
107+
108+
@session.era == :legacy ? [@session] : []
109+
end
110+
99111
def send_notification(method, params = nil)
100112
notification = {
101113
jsonrpc: "2.0",

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,20 @@ def close
509509
end
510510
end
511511

512+
# Sessions a server-wide log broadcast is offered to, each applying its own level or the server default.
513+
# Public only so that `Server#notify_log_message` can call it. Custom transports may implement this hook;
514+
# returning `nil` keeps the server-wide filter.
515+
#
516+
# @api private
517+
# @return [Array<ServerSession>]
518+
def logging_sessions
519+
@mutex.synchronize do
520+
@sessions.values.filter_map do |session|
521+
session[:server_session] if session[:get_sse_stream]
522+
end
523+
end
524+
end
525+
512526
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
513527
# `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below:
514528
# a resource updated by one session's tool call changed globally, so modern subscribers hear about it too.

‎test/mcp/server/transports/stdio_notification_integration_test.rb‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ module MCP
66
class Server
77
module Transports
88
class StdioNotificationIntegrationTest < ActiveSupport::TestCase
9+
include InitializeParamsTestHelper
10+
911
class MockIO
1012
attr_reader :output
1113

@@ -259,6 +261,69 @@ def puts(message)
259261
assert_equal Methods::NOTIFICATIONS_MESSAGE, third_notification["method"]
260262
assert_equal({ "data" => { "error" => "Connection Failed" }, "level" => "error" }, third_notification["params"])
261263
end
264+
265+
test "server log messages honor the stdio client's level instead of the application default" do
266+
default_logging = LoggingMessageNotification.new(level: "critical")
267+
@server.logging_message_notification = default_logging
268+
server = @server
269+
@server.define_tool(name: "log_messages") do
270+
server.notify_log_message(data: "debug log", level: "debug")
271+
server.notify_log_message(data: "broadcast log", level: "info")
272+
Tool::Response.new([{ type: "text", text: "ok" }])
273+
end
274+
requests = [
275+
{ jsonrpc: "2.0", id: 1, method: "initialize", params: initialize_params(protocolVersion: "2025-11-25") },
276+
{ jsonrpc: "2.0", method: "notifications/initialized" },
277+
{ jsonrpc: "2.0", id: 2, method: "logging/setLevel", params: { level: "info" } },
278+
{ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "log_messages", arguments: {} } },
279+
]
280+
frames = requests.map { |request| "#{request.to_json}\n" }
281+
@mock_stdin.stubs(:gets).returns(*frames, nil)
282+
283+
@transport.open
284+
285+
messages = @mock_stdout.output.map { |message| JSON.parse(message) }
286+
assert_equal({}, messages.find { |message| message["id"] == 2 }["result"])
287+
assert_equal "ok", messages.find { |message| message["id"] == 3 }["result"]["content"].first["text"]
288+
logs = messages.select { |message| message["method"] == Methods::NOTIFICATIONS_MESSAGE }
289+
assert_equal 1, logs.size
290+
assert_equal "broadcast log", logs.first["params"]["data"]
291+
assert_same default_logging, @server.logging_message_notification
292+
end
293+
294+
test "modern stdio logs stay request-scoped even with an application default" do
295+
@server.logging_message_notification = LoggingMessageNotification.new(level: "debug")
296+
server = @server
297+
@server.define_tool(name: "log_messages") do |server_context:|
298+
server.notify_log_message(data: "broadcast log", level: "info")
299+
server_context.notify_log_message(data: "request log", level: "error")
300+
Tool::Response.new([{ type: "text", text: "ok" }])
301+
end
302+
meta = {
303+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
304+
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "1.0" },
305+
"io.modelcontextprotocol/clientCapabilities": {},
306+
}
307+
requests = [
308+
{ jsonrpc: "2.0", id: 1, method: "server/discover" },
309+
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "log_messages", arguments: {}, _meta: meta } },
310+
{
311+
jsonrpc: "2.0", id: 3, method: "tools/call", params: {
312+
name: "log_messages", arguments: {}, _meta: meta.merge("io.modelcontextprotocol/logLevel": "error"),
313+
},
314+
},
315+
]
316+
frames = requests.map { |request| "#{request.to_json}\n" }
317+
@mock_stdin.stubs(:gets).returns(*frames, nil)
318+
319+
@transport.open
320+
321+
messages = @mock_stdout.output.map { |message| JSON.parse(message) }
322+
assert_equal "ok", messages.find { |message| message["id"] == 2 }["result"]["content"].first["text"]
323+
assert_equal "ok", messages.find { |message| message["id"] == 3 }["result"]["content"].first["text"]
324+
logs = messages.select { |message| message["method"] == Methods::NOTIFICATIONS_MESSAGE }
325+
assert_equal ["request log"], logs.map { |message| message["params"]["data"] }
326+
end
262327
end
263328
end
264329
end
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "rack/mock"
5+
6+
module MCP
7+
class Server
8+
module Transports
9+
class StreamableHTTPLoggingTest < ActiveSupport::TestCase
10+
include InitializeParamsTestHelper
11+
12+
setup do
13+
@server = Server.new(name: "test_server")
14+
@transport = StreamableHTTPTransport.new(@server)
15+
@transport.stubs(:start_keepalive_thread)
16+
end
17+
18+
teardown do
19+
@transport.close
20+
end
21+
22+
test "broadcasts use each session's level before the application default" do
23+
default_logging = LoggingMessageNotification.new(level: "warning")
24+
@server.logging_message_notification = default_logging
25+
debug_session, debug_stream = open_session
26+
error_session, error_stream = open_session
27+
_, default_stream = open_session
28+
29+
set_level(debug_session, "debug")
30+
set_level(error_session, "error")
31+
_, future_stream = open_session
32+
33+
["debug", "info", "warning", "error"].each do |level|
34+
@server.notify_log_message(data: { message: level }, level: level, logger: "ExampleLogger")
35+
end
36+
37+
assert_equal ["debug", "info", "warning", "error"], log_levels(debug_stream)
38+
assert_equal ["error"], log_levels(error_stream)
39+
assert_equal ["warning", "error"], log_levels(default_stream)
40+
assert_equal ["warning", "error"], log_levels(future_stream)
41+
assert_equal "ExampleLogger", messages(debug_stream).first["params"]["logger"]
42+
assert_same default_logging, @server.logging_message_notification
43+
end
44+
45+
test "one session setting a level does not enable broadcasts to other sessions" do
46+
session, stream = open_session
47+
_, other_stream = open_session
48+
set_level(session, "info")
49+
_, future_stream = open_session
50+
51+
@server.notify_log_message(data: "debug detail", level: "debug")
52+
@server.notify_log_message(data: "info detail", level: "info")
53+
54+
assert_equal ["info"], log_levels(stream)
55+
assert_empty messages(other_stream)
56+
assert_empty messages(future_stream)
57+
assert_nil @server.logging_message_notification
58+
end
59+
60+
private
61+
62+
def request(method, payload: nil, session_id: nil)
63+
env = Rack::MockRequest.env_for("http://localhost/", method: method)
64+
env["HTTP_ACCEPT"] = "application/json, text/event-stream"
65+
env["HTTP_MCP_PROTOCOL_VERSION"] = "2025-11-25"
66+
env["HTTP_MCP_SESSION_ID"] = session_id if session_id
67+
if payload
68+
env["CONTENT_TYPE"] = "application/json"
69+
env["rack.input"] = StringIO.new(payload.to_json)
70+
end
71+
@transport.call(env)
72+
end
73+
74+
def open_session
75+
status, headers, body = request("POST", payload: {
76+
jsonrpc: "2.0", id: 1, method: "initialize",
77+
params: initialize_params(protocolVersion: "2025-11-25"),
78+
})
79+
assert_equal 200, status
80+
assert_equal({}, JSON.parse(body.join).dig("result", "capabilities", "logging"))
81+
session_id = headers.fetch("mcp-session-id")
82+
status, = request("POST", session_id: session_id, payload: {
83+
jsonrpc: "2.0", method: "notifications/initialized",
84+
})
85+
assert_equal 202, status
86+
87+
status, headers, body = request("GET", session_id: session_id)
88+
assert_equal 200, status
89+
assert_equal "text/event-stream", headers["content-type"]
90+
stream = StringIO.new
91+
body.call(stream)
92+
[session_id, stream]
93+
end
94+
95+
def set_level(session_id, level)
96+
status, headers, body = request("POST", session_id: session_id, payload: {
97+
jsonrpc: "2.0", id: 2, method: "logging/setLevel", params: { level: level },
98+
})
99+
assert_equal 200, status
100+
assert_equal "text/event-stream", headers["content-type"]
101+
stream = StringIO.new
102+
body.call(stream)
103+
assert_equal({}, messages(stream).last["result"])
104+
end
105+
106+
def messages(stream)
107+
stream.string.scan(/^data: (.+)$/).flatten.map { |json| JSON.parse(json) }
108+
end
109+
110+
def log_levels(stream)
111+
messages(stream).map { |message| message["params"]["level"] }
112+
end
113+
end
114+
end
115+
end
116+
end

‎test/mcp/server_notification_test.rb‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class ServerNotificationTest < ActiveSupport::TestCase
99

1010
class MockTransport < Transport
1111
attr_reader :notifications
12+
attr_accessor :logging_sessions
1213

1314
def initialize(server)
1415
super
@@ -302,22 +303,93 @@ def send_notification(method, params = nil)
302303
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method]
303304
end
304305

306+
test "logging/setLevel does not enable logs in other existing or future sessions" do
307+
session = ServerSession.new(server: @server, transport: @mock_transport)
308+
other_session = ServerSession.new(server: @server, transport: @mock_transport)
309+
@server.define_tool(name: "log_debug") do |server_context:|
310+
server_context.notify_log_message(data: "debug detail", level: "debug")
311+
Tool::Response.new([{ type: "text", text: "ok" }])
312+
end
313+
314+
response = session.handle(
315+
{ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "debug" } },
316+
)
317+
assert_equal({}, response[:result])
318+
future_session = ServerSession.new(server: @server, transport: @mock_transport)
319+
320+
[other_session, future_session].each do |unconfigured_session|
321+
response = unconfigured_session.handle(
322+
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "log_debug", arguments: {} } },
323+
)
324+
assert_equal "ok", response[:result][:content].first[:text]
325+
end
326+
327+
assert_empty @mock_transport.notifications
328+
assert_nil @server.logging_message_notification
329+
330+
response = session.handle(
331+
{ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "log_debug", arguments: {} } },
332+
)
333+
assert_equal "ok", response[:result][:content].first[:text]
334+
assert_equal 1, @mock_transport.notifications.size
335+
assert_equal "debug detail", @mock_transport.notifications.first[:params]["data"]
336+
end
337+
338+
test "logging/setLevel does not change an application-configured server-wide default" do
339+
default_logging = LoggingMessageNotification.new(level: "error")
340+
@server.logging_message_notification = default_logging
341+
session = ServerSession.new(server: @server, transport: @mock_transport)
342+
other_session = ServerSession.new(server: @server, transport: @mock_transport)
343+
344+
response = session.handle(
345+
{ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "debug" } },
346+
)
347+
assert_equal({}, response[:result])
348+
future_session = ServerSession.new(server: @server, transport: @mock_transport)
349+
350+
[other_session, future_session].each do |unconfigured_session|
351+
unconfigured_session.notify_log_message(data: "info detail", level: "info")
352+
unconfigured_session.notify_log_message(data: "error detail", level: "error")
353+
end
354+
@server.notify_log_message(data: "broadcast info", level: "info")
355+
@server.notify_log_message(data: "broadcast error", level: "error")
356+
session.notify_log_message(data: "session debug", level: "debug")
357+
358+
assert_equal ["error detail", "error detail", "broadcast error", "session debug"],
359+
@mock_transport.notifications.map { |notification| notification[:params]["data"] }
360+
assert_same default_logging, @server.logging_message_notification
361+
end
362+
305363
test "server.notify_log_message works after logging/setLevel via session" do
306364
session = ServerSession.new(server: @server, transport: @mock_transport)
365+
@mock_transport.logging_sessions = [session]
307366

308367
# Client sends logging/setLevel through session.
309-
@server.handle(
368+
response = @server.handle(
310369
{ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } },
311370
session: session,
312371
)
372+
assert_equal({}, response[:result])
313373

314-
# Server-level broadcast should still work because logging level
315-
# is stored on both the session and the server.
316374
@server.notify_log_message(data: "broadcast log", level: "info")
317375

318376
log_notifications = @mock_transport.notifications.select { |n| n[:method] == Methods::NOTIFICATIONS_MESSAGE }
319377
assert_equal 1, log_notifications.size
320378
assert_equal "broadcast log", log_notifications.first[:params]["data"]
321379
end
380+
381+
test "logging/setLevel without a session configures server-wide broadcasts" do
382+
response = @server.handle(
383+
{ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } },
384+
)
385+
assert_equal({}, response[:result])
386+
387+
@server.notify_log_message(data: "debug log", level: "debug")
388+
@server.notify_log_message(data: "broadcast log", level: "info")
389+
390+
assert_equal 1, @mock_transport.notifications.size
391+
assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method]
392+
assert_equal "broadcast log", @mock_transport.notifications.first[:params]["data"]
393+
end
322394
end
323395
end

0 commit comments

Comments
 (0)