Skip to content

Commit 21c198f

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 21c198f

12 files changed

+430
-7
lines changed

README.md

Lines changed: 82 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,87 @@ 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+
#### Logging
128+
129+
The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging).
130+
131+
The `notifications/message` notification is used for structured logging between client and server.
132+
133+
##### Log Levels
134+
135+
The SDK supports 8 log levels with increasing severity:
136+
137+
| Level | Severity | Description |
138+
|-------|----------|-------------|
139+
| `debug` | 0 | Detailed debugging information |
140+
| `info` | 1 | General informational messages |
141+
| `notice` | 2 | Normal but significant events |
142+
| `warning` | 3 | Warning conditions |
143+
| `error` | 4 | Error conditions |
144+
| `critical` | 5 | Critical conditions |
145+
| `alert` | 6 | Action must be taken immediately |
146+
| `emergency` | 7 | System is unusable |
147+
148+
##### How Logging Works
149+
150+
1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level
151+
2. **Server Filtering**: The server only sends log messages at the configured level or higher severity
152+
3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client
153+
154+
For example, if the client sets the level to `"error"` (severity 4), the server will send messages with levels: `error`, `critical`, `alert`, and `emergency`.
155+
156+
##### Server Capability
157+
158+
According to the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#capabilities), servers that support logging **MUST** declare the `logging` capability. The Ruby SDK includes `logging: {}` in the default server capabilities automatically, so logging support is enabled by default without any additional configuration.
159+
160+
##### Using Logging in Your Server
161+
162+
```ruby
163+
server = MCP::Server.new(name: "my_server")
164+
transport = MCP::Server::Transports::StdioTransport.new(server)
165+
server.transport = transport
166+
167+
# The client first configures the logging level
168+
# Request: { "jsonrpc": "2.0", "method": "logging/setLevel", "params": { "level": "info" } }
169+
170+
# Send log messages at different severity levels
171+
server.notify_log_message(
172+
data: { message: "Application started successfully" },
173+
level: "info"
174+
)
175+
176+
server.notify_log_message(
177+
data: { message: "Configuration file not found, using defaults" },
178+
level: "warning"
179+
)
180+
181+
server.notify_log_message(
182+
data: {
183+
error: "Database connection failed",
184+
details: { host: "localhost", port: 5432 }
185+
},
186+
level: "error",
187+
logger: "DatabaseLogger" # Optional logger name
188+
)
189+
```
190+
191+
##### API Reference
192+
193+
**`notify_log_message(data:, level:, logger: nil)`**
194+
195+
Sends a structured log message to the client.
196+
197+
**Parameters:**
198+
- `data:` (Hash) - The log data/payload containing the message and any additional context
199+
- `level:` (String) - The severity level (must be one of: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`)
200+
- `logger:` (String, optional) - Name of the logger component sending the message
201+
202+
**Behavior:**
203+
- Messages are only sent if a transport is configured
204+
- Messages are filtered based on the client's configured log level
205+
- If the log level hasn't been set by the client, no messages will be sent
124206

125207
#### Transport Support
126208

@@ -141,7 +223,6 @@ server.notify_tools_list_changed
141223

142224
### Unsupported Features ( to be implemented in future versions )
143225

144-
- Log Level
145226
- Resource subscriptions
146227
- Completions
147228

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"]})")

lib/json_rpc_handler.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ def process_request(request, id_validation_pattern:, &method_finder)
105105
result = method.call(params)
106106

107107
success_response(id:, result:)
108+
rescue MCP::Server::RequestHandlerError => e
109+
handle_request_error(e, id, id_validation_pattern)
108110
rescue StandardError => e
109111
error_response(id:, id_validation_pattern:, error: {
110112
code: ErrorCode::INTERNAL_ERROR,
@@ -114,6 +116,24 @@ def process_request(request, id_validation_pattern:, &method_finder)
114116
end
115117
end
116118

119+
def handle_request_error(error, id, id_validation_pattern)
120+
error_type = error.respond_to?(:error_type) ? error.error_type : nil
121+
122+
code, message = case error_type
123+
when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"]
124+
when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]
125+
when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"]
126+
when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"]
127+
else [ErrorCode::INTERNAL_ERROR, "Internal error"]
128+
end
129+
130+
error_response(id:, id_validation_pattern:, error: {
131+
code:,
132+
message:,
133+
data: error.message,
134+
})
135+
end
136+
117137
def valid_version?(version)
118138
version == Version::V2_0
119139
end
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_LEVEL_SEVERITY = {
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_LEVEL_SEVERITY.keys.include?(level)
26+
end
27+
28+
def should_notify?(log_level)
29+
LOG_LEVEL_SEVERITY[log_level] >= LOG_LEVEL_SEVERITY[level]
30+
end
31+
end
32+
end

lib/mcp/server.rb

Lines changed: 30 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,18 @@ 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+
return unless logging_message_notification&.should_notify?(level)
148+
149+
params = { data:, level: }
150+
params[:logger] = logger if logger
151+
152+
@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
153+
rescue => e
154+
report_exception(e, { notification: "log_message" })
155+
end
156+
143157
def resources_list_handler(&block)
144158
@handlers[Methods::RESOURCES_LIST] = block
145159
end
@@ -232,6 +246,7 @@ def default_capabilities
232246
tools: { listChanged: true },
233247
prompts: { listChanged: true },
234248
resources: { listChanged: true },
249+
logging: {},
235250
}
236251
end
237252

@@ -252,6 +267,19 @@ def init(request)
252267
}.compact
253268
end
254269

270+
def logging_level=(request)
271+
if capabilities[:logging].nil?
272+
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
273+
end
274+
275+
logging_message_notification = LoggingMessageNotification.new(level: request[:level])
276+
unless logging_message_notification.valid_level?
277+
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
278+
end
279+
280+
@logging_message_notification = logging_message_notification
281+
end
282+
255283
def list_tools(request)
256284
@tools.values.map(&:to_h)
257285
end

test/json_rpc_handler_test.rb

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,96 @@
398398
}
399399
end
400400

401+
it "returns an error with the code set to -32600 when error_type of RequestHandlerError is :invalid_request" do
402+
register("test_method") do
403+
raise MCP::Server::RequestHandlerError.new(
404+
"Invalid request data",
405+
{},
406+
error_type: :invalid_request,
407+
)
408+
end
409+
410+
handle jsonrpc: "2.0", id: 1, method: "test_method"
411+
412+
assert_rpc_error expected_error: {
413+
code: -32600,
414+
message: "Invalid Request",
415+
data: "Invalid request data",
416+
}
417+
end
418+
419+
it "returns an error with the code set to -32602 when error_type of RequestHandlerError is :invalid_params" do
420+
register("test_method") do
421+
raise MCP::Server::RequestHandlerError.new(
422+
"Parameter validation failed",
423+
{},
424+
error_type: :invalid_params,
425+
)
426+
end
427+
428+
handle jsonrpc: "2.0", id: 1, method: "test_method"
429+
430+
assert_rpc_error expected_error: {
431+
code: -32602,
432+
message: "Invalid params",
433+
data: "Parameter validation failed",
434+
}
435+
end
436+
437+
it "returns an error with the code set to -32700 when error_type of RequestHandlerError is :parse_error" do
438+
register("test_method") do
439+
raise MCP::Server::RequestHandlerError.new(
440+
"Failed to parse input",
441+
{},
442+
error_type: :parse_error,
443+
)
444+
end
445+
446+
handle jsonrpc: "2.0", id: 1, method: "test_method"
447+
448+
assert_rpc_error expected_error: {
449+
code: -32700,
450+
message: "Parse error",
451+
data: "Failed to parse input",
452+
}
453+
end
454+
455+
it "returns an error with the code set to -32603 when error_type of RequestHandlerError is :internal_error" do
456+
register("test_method") do
457+
raise MCP::Server::RequestHandlerError.new(
458+
"Internal processing error",
459+
{},
460+
error_type: :internal_error,
461+
)
462+
end
463+
464+
handle jsonrpc: "2.0", id: 1, method: "test_method"
465+
466+
assert_rpc_error expected_error: {
467+
code: -32603,
468+
message: "Internal error",
469+
data: "Internal processing error",
470+
}
471+
end
472+
473+
it "returns an error with the code set to -32603 when error_type of RequestHandlerError is unknown" do
474+
register("test_method") do
475+
raise MCP::Server::RequestHandlerError.new(
476+
"Unknown error occurred",
477+
{},
478+
error_type: :unknown,
479+
)
480+
end
481+
482+
handle jsonrpc: "2.0", id: 1, method: "test_method"
483+
484+
assert_rpc_error expected_error: {
485+
code: -32603,
486+
message: "Internal error",
487+
data: "Unknown error occurred",
488+
}
489+
end
490+
401491
# 6 Batch
402492
#
403493
# To send several Request objects at the same time, the Client MAY send an Array filled with Request objects.

0 commit comments

Comments
 (0)