Skip to content

Commit c590295

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 eb0d9c0 commit c590295

10 files changed

+233
-7
lines changed

README.md

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

115116
#### Notification Format
116117

@@ -119,6 +120,28 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
119120
- `notifications/tools/list_changed`
120121
- `notifications/prompts/list_changed`
121122
- `notifications/resources/list_changed`
123+
- `notifications/message`
124+
125+
#### Notification Logging Message Flow
126+
127+
The `notifications/message` notification is used for structured logging between client and server.
128+
129+
1. **Client sends logging configuration**: The client first sends a `logging/setLevel` request to configure the desired log level.
130+
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.
131+
132+
##### Usage Example
133+
134+
```ruby
135+
# Client sets logging level
136+
# Request: { "jsonrpc": "2.0", "method": "logging/setLevel", "params": { "level": "error" } }
137+
138+
# Server sends notifications for log events
139+
server.notify_log_message(
140+
data: { error: "Connection Failed" },
141+
level: "error",
142+
logger: "DatabaseLogger"
143+
)
144+
```
122145

123146
#### Transport Support
124147

@@ -139,7 +162,6 @@ server.notify_tools_list_changed
139162

140163
### Unsupported Features ( to be implemented in future versions )
141164

142-
- Log Level
143165
- Resource subscriptions
144166
- Completions
145167

examples/streamable_http_client.rb

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

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

examples/streamable_http_server.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def call(message:, delay: 0)
109109
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
110110
elsif parsed_response["accepted"]
111111
# Response was sent via SSE
112+
server.notify_log_message(data: { details: "Response accepted and sent via SSE" }, level: "info")
112113
sse_logger.info("Response sent via SSE stream")
113114
else
114115
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+
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 "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",
@@ -65,6 +66,7 @@ def initialize(
6566
end
6667

6768
@capabilities = capabilities || default_capabilities
69+
@logging_message_notification = nil
6870

6971
@handlers = {
7072
Methods::RESOURCES_LIST => method(:list_resources),
@@ -77,12 +79,12 @@ def initialize(
7779
Methods::INITIALIZE => method(:init),
7880
Methods::PING => ->(_) { {} },
7981
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
82+
Methods::LOGGING_SET_LEVEL => method(:logging_level=),
8083

8184
# No op handlers for currently unsupported methods
8285
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
8386
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
8487
Methods::COMPLETION_COMPLETE => ->(_) {},
85-
Methods::LOGGING_SET_LEVEL => ->(_) {},
8688
}
8789
@transport = transport
8890
end
@@ -141,6 +143,21 @@ def notify_resources_list_changed
141143
report_exception(e, { notification: "resources_list_changed" })
142144
end
143145

146+
def notify_log_message(data:, level:, logger: nil)
147+
return unless @transport
148+
unless logging_message_notification
149+
raise RequestHandlerError.new("logging_message_notification must not be null", {}, error_type: :logging_message_notification_not_specified)
150+
end
151+
return unless logging_message_notification.should_notify?(level)
152+
153+
params = { data:, level: }
154+
params[:logger] = logger if logger
155+
156+
@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
157+
rescue => e
158+
report_exception(e, { notification: "log_message" })
159+
end
160+
144161
def resources_list_handler(&block)
145162
@handlers[Methods::RESOURCES_LIST] = block
146163
end
@@ -214,6 +231,7 @@ def default_capabilities
214231
tools: { listChanged: true },
215232
prompts: { listChanged: true },
216233
resources: { listChanged: true },
234+
logging: {},
217235
}
218236
end
219237

@@ -234,6 +252,15 @@ def init(request)
234252
}.compact
235253
end
236254

255+
def logging_level=(request)
256+
logging_message_notification = LoggingMessageNotification.new(level: request[:level])
257+
unless logging_message_notification.valid_level?
258+
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_log_level)
259+
end
260+
261+
@logging_message_notification = logging_message_notification
262+
end
263+
237264
def list_tools(request)
238265
@tools.map { |_, tool| tool.to_h }
239266
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
@@ -67,14 +67,36 @@ def handle_request(request); end
6767
assert_nil notification[:params]
6868
end
6969

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

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

@@ -87,16 +109,18 @@ def send_notification(method, params = nil)
87109
end.new(@server)
88110

89111
@server.transport = error_transport
112+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
90113

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

98122
call_count = 0
99-
@server.configuration.exception_reporter.expects(:call).times(3).with do |exception, context|
123+
@server.configuration.exception_reporter.expects(:call).times(4).with do |exception, context|
100124
assert_kind_of StandardError, exception
101125
assert_equal "Transport error", exception.message
102126
assert_includes expected_contexts, context
@@ -109,22 +133,26 @@ def send_notification(method, params = nil)
109133
@server.notify_tools_list_changed
110134
@server.notify_prompts_list_changed
111135
@server.notify_resources_list_changed
136+
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
112137
end
113138

114-
assert_equal 3, call_count
139+
assert_equal 4, call_count
115140
end
116141

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

122-
assert_equal 3, @mock_transport.notifications.size
149+
assert_equal 4, @mock_transport.notifications.size
123150

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

0 commit comments

Comments
 (0)