Skip to content

Commit 378a469

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 022b3ff commit 378a469

10 files changed

+223
-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+
level: "error",
141+
data: { error: "Connection Failed" },
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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# frozen_string_literal: true
2+
3+
require "json_rpc_handler"
4+
5+
module MCP
6+
class LoggingMessageNotification
7+
LOG_LEVELS = {
8+
"emergency" => 7,
9+
"alert" => 6,
10+
"critical" => 5,
11+
"error" => 4,
12+
"warning" => 3,
13+
"notice" => 2,
14+
"info" => 1,
15+
"debug" => 0,
16+
}.freeze
17+
18+
attr_reader :level
19+
20+
class InvalidLevelError < StandardError
21+
def initialize
22+
super("Invalid log level provided. Valid levels are: #{LOG_LEVELS.keys.join(", ")}")
23+
@code = JsonRpcHandler::ErrorCode::InvalidParams
24+
end
25+
end
26+
27+
class NotSpecifiedLevelError < StandardError
28+
def initialize
29+
super("Log level not specified. Please set a valid log level.")
30+
@code = JsonRpcHandler::ErrorCode::InternalError
31+
end
32+
end
33+
34+
def initialize(level:)
35+
@level = level
36+
end
37+
38+
def valid_level?
39+
LOG_LEVELS.keys.include?(level)
40+
end
41+
42+
def should_notify?(log_level:)
43+
LOG_LEVELS[log_level] >= LOG_LEVELS[level]
44+
end
45+
end
46+
end

lib/mcp/server.rb

Lines changed: 26 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, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
35+
attr_accessor :name, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
3536

3637
def initialize(
3738
name: "model_context_protocol",
@@ -63,6 +64,7 @@ def initialize(
6364
end
6465

6566
@capabilities = capabilities || default_capabilities
67+
@logging_message_notification = nil
6668

6769
@handlers = {
6870
Methods::RESOURCES_LIST => method(:list_resources),
@@ -75,12 +77,12 @@ def initialize(
7577
Methods::INITIALIZE => method(:init),
7678
Methods::PING => ->(_) { {} },
7779
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
80+
Methods::LOGGING_SET_LEVEL => method(:logging_level=),
7881

7982
# No op handlers for currently unsupported methods
8083
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
8184
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
8285
Methods::COMPLETION_COMPLETE => ->(_) {},
83-
Methods::LOGGING_SET_LEVEL => ->(_) {},
8486
}
8587
@transport = transport
8688
end
@@ -139,6 +141,20 @@ def notify_resources_list_changed
139141
report_exception(e, { notification: "resources_list_changed" })
140142
end
141143

144+
def notify_log_message(data:, level:, logger: nil)
145+
return unless @transport
146+
raise LoggingMessageNotification::NotSpecifiedLevelError unless logging_message_notification&.level
147+
148+
return unless logging_message_notification.should_notify?(log_level: level)
149+
150+
params = { data:, level: }
151+
params[:logger] = logger if logger
152+
153+
@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
154+
rescue => e
155+
report_exception(e, { notification: "logging_message_notification" })
156+
end
157+
142158
def resources_list_handler(&block)
143159
@handlers[Methods::RESOURCES_LIST] = block
144160
end
@@ -212,6 +228,7 @@ def default_capabilities
212228
tools: { listChanged: true },
213229
prompts: { listChanged: true },
214230
resources: { listChanged: true },
231+
logging: {},
215232
}
216233
end
217234

@@ -231,6 +248,13 @@ def init(request)
231248
}.compact
232249
end
233250

251+
def logging_level=(params)
252+
logging_message_notification = LoggingMessageNotification.new(level: params[:level])
253+
raise LoggingMessageNotification::InvalidLevelError unless logging_message_notification.valid_level?
254+
255+
@logging_message_notification = logging_message_notification
256+
end
257+
234258
def list_tools(request)
235259
@tools.map { |_, tool| tool.to_h }
236260
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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 "InvalidLevelError has correct error code" do
23+
error = LoggingMessageNotification::InvalidLevelError.new
24+
assert_equal(-32602, error.instance_variable_get(:@code))
25+
end
26+
27+
test "InvalidLevelError message format" do
28+
error = LoggingMessageNotification::InvalidLevelError.new
29+
expected_levels = LoggingMessageNotification::LOG_LEVELS.keys.join(", ")
30+
expected_message = "Invalid log level provided. Valid levels are: #{expected_levels}"
31+
32+
assert_equal expected_message, error.message
33+
end
34+
35+
test "NotSpecifiedLevelError has correct error code" do
36+
error = LoggingMessageNotification::NotSpecifiedLevelError.new
37+
assert_equal(-32603, error.instance_variable_get(:@code))
38+
end
39+
40+
test "NotSpecifiedLevelError has correct message" do
41+
error = LoggingMessageNotification::NotSpecifiedLevelError.new
42+
expected_message = "Log level not specified. Please set a valid log level."
43+
44+
assert_equal expected_message, error.message
45+
end
46+
47+
test "should_notify? returns true when notification level is higher priority than threshold level or equals to it" do
48+
logging_message_notification = LoggingMessageNotification.new(level: "warning")
49+
assert logging_message_notification.should_notify?(log_level: "warning")
50+
assert logging_message_notification.should_notify?(log_level: "error")
51+
assert logging_message_notification.should_notify?(log_level: "critical")
52+
assert logging_message_notification.should_notify?(log_level: "alert")
53+
assert logging_message_notification.should_notify?(log_level: "emergency")
54+
end
55+
56+
test "should_notify? returns false when notification level is lower priority than threshold level" do
57+
logging_message_notification = LoggingMessageNotification.new(level: "warning")
58+
refute logging_message_notification.should_notify?(log_level: "notice")
59+
refute logging_message_notification.should_notify?(log_level: "info")
60+
refute logging_message_notification.should_notify?(log_level: "debug")
61+
end
62+
end
63+
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

0 commit comments

Comments
 (0)