Skip to content

Commit 2ae5995

Browse files
committed
Add logging support
A server can send structured logging messages to the client. https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#logging Logging was specified in the 2024-11-05 specification, but since it was not supported in ruby-sdk, I implemented it. https://modelcontextprotocol.io/specification/2024-11-05/server/utilities/logging I also made it possible to output a simple notification message in the examples.
1 parent 3f5f6f8 commit 2ae5995

10 files changed

+233
-7
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ The server provides three notification methods:
113113
- `notify_tools_list_changed` - Send a notification when the tools list changes
114114
- `notify_prompts_list_changed` - Send a notification when the prompts list changes
115115
- `notify_resources_list_changed` - Send a notification when the resources list changes
116+
- `notify_log_message` - Send a structured logging notification message
116117

117118
#### Notification Format
118119

@@ -121,6 +122,28 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
121122
- `notifications/tools/list_changed`
122123
- `notifications/prompts/list_changed`
123124
- `notifications/resources/list_changed`
125+
- `notifications/message`
126+
127+
#### Notification Logging Message Flow
128+
129+
The `notifications/message` notification is used for structured logging between client and server.
130+
131+
1. **Client sends logging configuration**: The client first sends a `logging/setLevel` request to configure the desired log level.
132+
2. **Server processes and notifies**: Upon receiving the log level configuration, the server uses `notify_log_message` to send log messages at the configured level and higher priority levels.For example, if "error" is configured, the server can send "error", "critical", "alert", and "emergency" messages. Please refer to `lib/mcp/logging_message_notification.rb` for log priorities in details.
133+
134+
##### Usage Example
135+
136+
```ruby
137+
# Client sets logging level
138+
# Request: { "jsonrpc": "2.0", "method": "logging/setLevel", "params": { "level": "error" } }
139+
140+
# Server sends notifications for log events
141+
server.notify_log_message(
142+
data: { error: "Connection Failed" },
143+
level: "error",
144+
logger: "DatabaseLogger"
145+
)
146+
```
124147

125148
#### Transport Support
126149

@@ -141,7 +164,6 @@ server.notify_tools_list_changed
141164

142165
### Unsupported Features ( to be implemented in future versions )
143166

144-
- Log Level
145167
- Resource subscriptions
146168
- Completions
147169

examples/streamable_http_client.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ def main
122122
exit(1)
123123
end
124124

125+
if init_response[:body].dig("result", "capabilities", "logging")
126+
make_request(session_id, "logging/setLevel", { level: "info" })
127+
end
128+
125129
logger.info("Session initialized: #{session_id}")
126130
logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")
127131

examples/streamable_http_server.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def call(message:, delay: 0)
107107
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
108108
elsif parsed_response["accepted"]
109109
# Response was sent via SSE
110+
server.notify_log_message(data: { details: "Response accepted and sent via SSE" }, level: "info")
110111
sse_logger.info("Response sent via SSE stream")
111112
else
112113
mcp_logger.info("Response: success (id: #{parsed_response["id"]})")
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
require "json_rpc_handler"
4+
5+
module MCP
6+
class LoggingMessageNotification
7+
LOG_LEVELS = {
8+
"debug" => 0,
9+
"info" => 1,
10+
"notice" => 2,
11+
"warning" => 3,
12+
"error" => 4,
13+
"critical" => 5,
14+
"alert" => 6,
15+
"emergency" => 7,
16+
}.freeze
17+
18+
private attr_reader :level
19+
20+
def initialize(level:)
21+
@level = level
22+
end
23+
24+
def valid_level?
25+
LOG_LEVELS.keys.include?(level)
26+
end
27+
28+
def should_notify?(log_level)
29+
LOG_LEVELS[log_level] >= LOG_LEVELS[level]
30+
end
31+
end
32+
end

lib/mcp/server.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require_relative "../json_rpc_handler"
44
require_relative "instrumentation"
55
require_relative "methods"
6+
require_relative "logging_message_notification"
67

78
module MCP
89
class Server
@@ -31,7 +32,7 @@ def initialize(method_name)
3132

3233
include Instrumentation
3334

34-
attr_accessor :name, :title, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
35+
attr_accessor :name, :title, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
3536

3637
def initialize(
3738
name: "model_context_protocol",
@@ -62,6 +63,7 @@ def initialize(
6263
validate!
6364

6465
@capabilities = capabilities || default_capabilities
66+
@logging_message_notification = nil
6567

6668
@handlers = {
6769
Methods::RESOURCES_LIST => method(:list_resources),
@@ -74,12 +76,12 @@ def initialize(
7476
Methods::INITIALIZE => method(:init),
7577
Methods::PING => ->(_) { {} },
7678
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
79+
Methods::LOGGING_SET_LEVEL => method(:logging_level=),
7780

7881
# No op handlers for currently unsupported methods
7982
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
8083
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
8184
Methods::COMPLETION_COMPLETE => ->(_) {},
82-
Methods::LOGGING_SET_LEVEL => ->(_) {},
8385
}
8486
@transport = transport
8587
end
@@ -140,6 +142,21 @@ def notify_resources_list_changed
140142
report_exception(e, { notification: "resources_list_changed" })
141143
end
142144

145+
def notify_log_message(data:, level:, logger: nil)
146+
return unless @transport
147+
unless logging_message_notification
148+
raise RequestHandlerError.new("logging_message_notification must not be null", {}, error_type: :logging_message_notification_not_specified)
149+
end
150+
return unless logging_message_notification.should_notify?(level)
151+
152+
params = { data:, level: }
153+
params[:logger] = logger if logger
154+
155+
@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
156+
rescue => e
157+
report_exception(e, { notification: "log_message" })
158+
end
159+
143160
def resources_list_handler(&block)
144161
@handlers[Methods::RESOURCES_LIST] = block
145162
end
@@ -232,6 +249,7 @@ def default_capabilities
232249
tools: { listChanged: true },
233250
prompts: { listChanged: true },
234251
resources: { listChanged: true },
252+
logging: {},
235253
}
236254
end
237255

@@ -252,6 +270,15 @@ def init(request)
252270
}.compact
253271
end
254272

273+
def logging_level=(request)
274+
logging_message_notification = LoggingMessageNotification.new(level: request[:level])
275+
unless logging_message_notification.valid_level?
276+
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_log_level)
277+
end
278+
279+
@logging_message_notification = logging_message_notification
280+
end
281+
255282
def list_tools(request)
256283
@tools.values.map(&:to_h)
257284
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module MCP
6+
class LoggingMessageNotificationTest < ActiveSupport::TestCase
7+
test "valid_level? returns true for valid levels" do
8+
LoggingMessageNotification::LOG_LEVELS.keys.each do |level|
9+
logging_message_notification = LoggingMessageNotification.new(level: level)
10+
assert logging_message_notification.valid_level?, "#{level} should be valid"
11+
end
12+
end
13+
14+
test "valid_level? returns false for invalid levels" do
15+
invalid_levels = ["invalid", 1, "", nil, :fatal]
16+
invalid_levels.each do |level|
17+
logging_message_notification = LoggingMessageNotification.new(level: level)
18+
refute logging_message_notification.valid_level?, "#{level} should be invalid"
19+
end
20+
end
21+
22+
test "should_notify? returns true when notification level is higher priority than threshold level or equals to it" do
23+
logging_message_notification = LoggingMessageNotification.new(level: "warning")
24+
assert logging_message_notification.should_notify?("warning")
25+
assert logging_message_notification.should_notify?("error")
26+
assert logging_message_notification.should_notify?("critical")
27+
assert logging_message_notification.should_notify?("alert")
28+
assert logging_message_notification.should_notify?("emergency")
29+
end
30+
31+
test "should_notify? returns false when notification level is lower priority than threshold level" do
32+
logging_message_notification = LoggingMessageNotification.new(level: "warning")
33+
refute logging_message_notification.should_notify?("notice")
34+
refute logging_message_notification.should_notify?("info")
35+
refute logging_message_notification.should_notify?("debug")
36+
end
37+
end
38+
end

test/mcp/server/transports/stdio_notification_integration_test.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ def closed?
7979
# Test resources notification
8080
@server.notify_resources_list_changed
8181

82+
# Test log notification
83+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
84+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
85+
8286
# Check the notifications were sent
83-
assert_equal 3, @mock_stdout.output.size
87+
assert_equal 4, @mock_stdout.output.size
8488

8589
# Parse and verify each notification
8690
notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) }
@@ -96,6 +100,10 @@ def closed?
96100
assert_equal "2.0", notifications[2]["jsonrpc"]
97101
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2]["method"]
98102
assert_nil notifications[2]["params"]
103+
104+
assert_equal "2.0", notifications[3]["jsonrpc"]
105+
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3]["method"]
106+
assert_equal({ "level" => "error", "data" => { "error" => "Connection Failed" } }, notifications[3]["params"])
99107
end
100108

101109
test "notifications include params when provided" do
@@ -120,6 +128,7 @@ def closed?
120128
@server.notify_tools_list_changed
121129
@server.notify_prompts_list_changed
122130
@server.notify_resources_list_changed
131+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
123132
end
124133
end
125134

@@ -240,6 +249,16 @@ def puts(message)
240249
assert_equal 2, @mock_stdout.output.size
241250
second_notification = JSON.parse(@mock_stdout.output.last)
242251
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, second_notification["method"]
252+
253+
# Set log level and notify
254+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
255+
256+
# Manually trigger notification
257+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
258+
assert_equal 3, @mock_stdout.output.size
259+
third_notification = JSON.parse(@mock_stdout.output.last)
260+
assert_equal Methods::NOTIFICATIONS_MESSAGE, third_notification["method"]
261+
assert_equal({ "data" => { "error" => "Connection Failed" }, "level" => "error" }, third_notification["params"])
243262
end
244263
end
245264
end

test/mcp/server/transports/streamable_http_notification_integration_test.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,20 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
5151
# Test resources notification
5252
@server.notify_resources_list_changed
5353

54+
# Set log level to error for log notification
55+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
56+
57+
# Test log notification
58+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
59+
5460
# Check the notifications were received
5561
io.rewind
5662
output = io.read
5763

5864
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}\"}"
5965
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED}\"}"
6066
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED}\"}"
67+
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_MESSAGE}\",\"params\":{\"data\":{\"error\":\"Connection Failed\"},\"level\":\"error\"}}\n\n"
6168
end
6269

6370
test "notifications are broadcast to all connected sessions" do
@@ -147,6 +154,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
147154
@server.notify_tools_list_changed
148155
@server.notify_prompts_list_changed
149156
@server.notify_resources_list_changed
157+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
150158
end
151159
end
152160

test/mcp/server_notification_test.rb

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,36 @@ def handle_request(request); end
6666
assert_nil notification[:params]
6767
end
6868

69+
test "#notify_log_message sends notification through transport" do
70+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
71+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
72+
73+
assert_equal 1, @mock_transport.notifications.size
74+
notification = @mock_transport.notifications.first
75+
assert_equal Methods::NOTIFICATIONS_MESSAGE, notification[:method]
76+
assert_equal({ data: { error: "Connection Failed" }, level: "error" }, notification[:params])
77+
end
78+
79+
test "#notify_log_message sends notification with logger through transport" do
80+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
81+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger")
82+
83+
assert_equal 1, @mock_transport.notifications.size
84+
notification = @mock_transport.notifications.first
85+
assert_equal Methods::NOTIFICATIONS_MESSAGE, notification[:method]
86+
assert_equal({ data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger" }, notification[:params])
87+
end
88+
6989
test "notification methods work without transport" do
7090
server_without_transport = Server.new(name: "test_server")
91+
server_without_transport.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
7192

7293
# Should not raise any errors
7394
assert_nothing_raised do
7495
server_without_transport.notify_tools_list_changed
7596
server_without_transport.notify_prompts_list_changed
7697
server_without_transport.notify_resources_list_changed
98+
server_without_transport.notify_log_message(data: { error: "Connection Failed" }, level: "error")
7799
end
78100
end
79101

@@ -86,16 +108,18 @@ def send_notification(method, params = nil)
86108
end.new(@server)
87109

88110
@server.transport = error_transport
111+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
89112

90113
# Mock the exception reporter
91114
expected_contexts = [
92115
{ notification: "tools_list_changed" },
93116
{ notification: "prompts_list_changed" },
94117
{ notification: "resources_list_changed" },
118+
{ notification: "log_message" },
95119
]
96120

97121
call_count = 0
98-
@server.configuration.exception_reporter.expects(:call).times(3).with do |exception, context|
122+
@server.configuration.exception_reporter.expects(:call).times(4).with do |exception, context|
99123
assert_kind_of StandardError, exception
100124
assert_equal "Transport error", exception.message
101125
assert_includes expected_contexts, context
@@ -108,22 +132,26 @@ def send_notification(method, params = nil)
108132
@server.notify_tools_list_changed
109133
@server.notify_prompts_list_changed
110134
@server.notify_resources_list_changed
135+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
111136
end
112137

113-
assert_equal 3, call_count
138+
assert_equal 4, call_count
114139
end
115140

116141
test "multiple notification methods can be called in sequence" do
117142
@server.notify_tools_list_changed
118143
@server.notify_prompts_list_changed
119144
@server.notify_resources_list_changed
145+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
146+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
120147

121-
assert_equal 3, @mock_transport.notifications.size
148+
assert_equal 4, @mock_transport.notifications.size
122149

123150
notifications = @mock_transport.notifications
124151
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method]
125152
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method]
126153
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method]
154+
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method]
127155
end
128156
end
129157
end

0 commit comments

Comments
 (0)