Skip to content

Commit aa15a5b

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 122bbd0 commit aa15a5b

10 files changed

+161
-7
lines changed

README.md

Lines changed: 2 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_logging_message()` - Send a structured logging notification message
114115

115116
#### Notification Format
116117

@@ -119,6 +120,7 @@ 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`
122124

123125
#### Transport Support
124126

@@ -139,7 +141,6 @@ server.notify_tools_list_changed()
139141

140142
### Unsupported Features ( to be implemented in future versions )
141143

142-
- Log Level
143144
- Resource subscriptions
144145
- Completions
145146

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_logging_message(data: { details: "Response accepted and sent via SSE" })
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 = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"].freeze
8+
attr_reader :level
9+
10+
class InvalidLevelError < StandardError
11+
def initialize
12+
super("Invalid log level provided. Valid levels are: #{LOG_LEVELS.join(", ")}")
13+
@code = JsonRpcHandler::ErrorCode::InvalidParams
14+
end
15+
end
16+
17+
class NotSpecifiedLevelError < StandardError
18+
def initialize
19+
super("Log level not specified. Please set a valid log level.")
20+
@code = JsonRpcHandler::ErrorCode::InternalError
21+
end
22+
end
23+
24+
def initialize(level:)
25+
@level = level
26+
end
27+
28+
def valid_level?
29+
LOG_LEVELS.include?(level)
30+
end
31+
end
32+
end

lib/mcp/server.rb

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

3637
def initialize(
3738
name: "model_context_protocol",
@@ -55,6 +56,7 @@ def initialize(
5556
@server_context = server_context
5657
@configuration = MCP.configuration.merge(configuration)
5758
@capabilities = capabilities || default_capabilities
59+
@logging_message_notification = nil
5860

5961
@handlers = {
6062
Methods::RESOURCES_LIST => method(:list_resources),
@@ -66,12 +68,12 @@ def initialize(
6668
Methods::PROMPTS_GET => method(:get_prompt),
6769
Methods::INITIALIZE => method(:init),
6870
Methods::PING => ->(_) { {} },
71+
Methods::LOGGING_SET_LEVEL => method(:logging_level=),
6972

7073
# No op handlers for currently unsupported methods
7174
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
7275
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
7376
Methods::COMPLETION_COMPLETE => ->(_) {},
74-
Methods::LOGGING_SET_LEVEL => ->(_) {},
7577
}
7678
@transport = transport
7779
end
@@ -130,6 +132,19 @@ def notify_resources_list_changed
130132
report_exception(e, { notification: "resources_list_changed" })
131133
end
132134

135+
def notify_logging_message(logger: nil, data: nil)
136+
return unless @transport
137+
raise LoggingMessageNotification::NotSpecifiedLevelError unless logging_message_notification&.level
138+
139+
params = { level: logging_message_notification.level }
140+
params[:logger] = logger if logger
141+
params[:data] = data if data
142+
143+
@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
144+
rescue => e
145+
report_exception(e, { notification: "logging_message_notification" })
146+
end
147+
133148
def resources_list_handler(&block)
134149
@handlers[Methods::RESOURCES_LIST] = block
135150
end
@@ -203,6 +218,7 @@ def default_capabilities
203218
tools: { listChanged: true },
204219
prompts: { listChanged: true },
205220
resources: { listChanged: true },
221+
logging: {},
206222
}
207223
end
208224

@@ -221,6 +237,13 @@ def init(request)
221237
}
222238
end
223239

240+
def logging_level=(level)
241+
logging_message_notification = LoggingMessageNotification.new(level: level)
242+
raise LoggingMessageNotification::InvalidLevelError unless logging_message_notification.valid_level?
243+
244+
@logging_message_notification = logging_message_notification
245+
end
246+
224247
def list_tools(request)
225248
@tools.map { |_, tool| tool.to_h }
226249
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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.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+
assert_not 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.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+
end
47+
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_logging_message(data: { error: "Connection Failed" })
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_logging_message(data: { error: "Connection Failed" })
123132
end
124133
end
125134

@@ -239,6 +248,16 @@ def puts(message)
239248
assert_equal 2, @mock_stdout.output.size
240249
second_notification = JSON.parse(@mock_stdout.output.last)
241250
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, second_notification["method"]
251+
252+
# Set log level and notify
253+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
254+
255+
# Manually trigger notification
256+
@server.notify_logging_message(data: { error: "Connection Failed" })
257+
assert_equal 3, @mock_stdout.output.size
258+
third_notification = JSON.parse(@mock_stdout.output.last)
259+
assert_equal Methods::NOTIFICATIONS_MESSAGE, third_notification["method"]
260+
assert_equal({ "level" => "error", "data" => { "error" => "Connection Failed" } }, third_notification["params"])
242261
end
243262
end
244263
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_logging_message(data: { error: "Connection Failed" })
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\":{\"level\":\"error\",\"data\":{\"error\":\"Connection Failed\"}}}\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_logging_message(data: { error: "Connection Failed" })
150158
end
151159
end
152160

test/mcp/server_notification_test.rb

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,26 @@ def handle_request(request); end
6767
assert_nil notification[:params]
6868
end
6969

70+
test "#notify_logging_message sends notification through transport" do
71+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
72+
@server.notify_logging_message(data: { error: "Connection Failed" })
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 notification[:params], { level: "error", data: { error: "Connection Failed" } }
78+
end
79+
7080
test "notification methods work without transport" do
7181
server_without_transport = Server.new(name: "test_server")
82+
server_without_transport.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
7283

7384
# Should not raise any errors
7485
assert_nothing_raised do
7586
server_without_transport.notify_tools_list_changed
7687
server_without_transport.notify_prompts_list_changed
7788
server_without_transport.notify_resources_list_changed
89+
server_without_transport.notify_logging_message(data: { error: "Connection Failed" })
7890
end
7991
end
8092

@@ -87,16 +99,18 @@ def send_notification(method, params = nil)
8799
end.new(@server)
88100

89101
@server.transport = error_transport
102+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
90103

91104
# Mock the exception reporter
92105
expected_contexts = [
93106
{ notification: "tools_list_changed" },
94107
{ notification: "prompts_list_changed" },
95108
{ notification: "resources_list_changed" },
109+
{ notification: "logging_message_notification" },
96110
]
97111

98112
call_count = 0
99-
@server.configuration.exception_reporter.expects(:call).times(3).with do |exception, context|
113+
@server.configuration.exception_reporter.expects(:call).times(4).with do |exception, context|
100114
assert_kind_of StandardError, exception
101115
assert_equal "Transport error", exception.message
102116
assert_includes expected_contexts, context
@@ -109,22 +123,26 @@ def send_notification(method, params = nil)
109123
@server.notify_tools_list_changed
110124
@server.notify_prompts_list_changed
111125
@server.notify_resources_list_changed
126+
@server.notify_logging_message(data: { error: "Connection Failed" })
112127
end
113128

114-
assert_equal 3, call_count
129+
assert_equal 4, call_count
115130
end
116131

117132
test "multiple notification methods can be called in sequence" do
118133
@server.notify_tools_list_changed
119134
@server.notify_prompts_list_changed
120135
@server.notify_resources_list_changed
136+
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
137+
@server.notify_logging_message(data: { error: "Connection Failed" })
121138

122-
assert_equal 3, @mock_transport.notifications.size
139+
assert_equal 4, @mock_transport.notifications.size
123140

124141
notifications = @mock_transport.notifications
125142
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method]
126143
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method]
127144
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method]
145+
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method]
128146
end
129147
end
130148
end

test/mcp/server_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ class ServerTest < ActiveSupport::TestCase
128128
prompts: { listChanged: true },
129129
resources: { listChanged: true },
130130
tools: { listChanged: true },
131+
logging: {},
131132
},
132133
serverInfo: {
133134
name: @server_name,

0 commit comments

Comments
 (0)