Skip to content

Commit bdb0056

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 830913b commit bdb0056

12 files changed

+436
-7
lines changed

README.md

Lines changed: 79 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,84 @@ 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+
For more details, see the [MCP Logging specification](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging).
157+
158+
**Usage Example:**
159+
160+
```ruby
161+
server = MCP::Server.new(name: "my_server")
162+
transport = MCP::Server::Transports::StdioTransport.new(server)
163+
server.transport = transport
164+
165+
# The client first configures the logging level (on the client side):
166+
transport.send_request(
167+
request: {
168+
jsonrpc: "2.0",
169+
method: "logging/setLevel",
170+
params: { level: "info" },
171+
id: session_id # Unique request ID within the session
172+
}
173+
)
174+
175+
# Send log messages at different severity levels
176+
server.notify_log_message(
177+
data: { message: "Application started successfully" },
178+
level: "info"
179+
)
180+
181+
server.notify_log_message(
182+
data: { message: "Configuration file not found, using defaults" },
183+
level: "warning"
184+
)
185+
186+
server.notify_log_message(
187+
data: {
188+
error: "Database connection failed",
189+
details: { host: "localhost", port: 5432 }
190+
},
191+
level: "error",
192+
logger: "DatabaseLogger" # Optional logger name
193+
)
194+
```
195+
196+
**Key Features:**
197+
198+
- Supports 8 log levels (debug, info, notice, warning, error, critical, alert, emergency) based on https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging#log-levels
199+
- Server has capability `logging` to send log messages
200+
- Messages are only sent if a transport is configured
201+
- Messages are filtered based on the client's configured log level
202+
- If the log level hasn't been set by the client, no messages will be sent
124203

125204
#### Transport Support
126205

@@ -153,7 +232,6 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, statele
153232

154233
### Unsupported Features (to be implemented in future versions)
155234

156-
- Log Level
157235
- Resource subscriptions
158236
- Completions
159237
- Elicitation

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: id, result: 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, id_validation_pattern: 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, id_validation_pattern: id_validation_pattern, error: {
131+
code: code,
132+
message: 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+
def initialize(level:)
19+
@level = level
20+
end
21+
22+
def valid_level?
23+
LOG_LEVEL_SEVERITY.keys.include?(@level)
24+
end
25+
26+
def should_notify?(log_level)
27+
return false unless LOG_LEVEL_SEVERITY.key?(log_level)
28+
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 ToolNotUnique < StandardError
@@ -40,7 +41,7 @@ def initialize(method_name)
4041

4142
include Instrumentation
4243

43-
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
44+
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
4445

4546
def initialize(
4647
description: nil,
@@ -78,6 +79,7 @@ def initialize(
7879
validate!
7980

8081
@capabilities = capabilities || default_capabilities
82+
@logging_message_notification = nil
8183

8284
@handlers = {
8385
Methods::RESOURCES_LIST => method(:list_resources),
@@ -90,12 +92,12 @@ def initialize(
9092
Methods::INITIALIZE => method(:init),
9193
Methods::PING => ->(_) { {} },
9294
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
95+
Methods::LOGGING_SET_LEVEL => method(:logging_level=),
9396

9497
# No op handlers for currently unsupported methods
9598
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
9699
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
97100
Methods::COMPLETION_COMPLETE => ->(_) {},
98-
Methods::LOGGING_SET_LEVEL => ->(_) {},
99101
Methods::ELICITATION_CREATE => ->(_) {},
100102
}
101103
@transport = transport
@@ -162,6 +164,18 @@ def notify_resources_list_changed
162164
report_exception(e, { notification: "resources_list_changed" })
163165
end
164166

167+
def notify_log_message(data:, level:, logger: nil)
168+
return unless @transport
169+
return unless logging_message_notification&.should_notify?(level)
170+
171+
params = { "data" => data, "level" => level }
172+
params["logger"] = logger if logger
173+
174+
@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
175+
rescue => e
176+
report_exception(e, { notification: "log_message" })
177+
end
178+
165179
def resources_list_handler(&block)
166180
@handlers[Methods::RESOURCES_LIST] = block
167181
end
@@ -284,6 +298,7 @@ def default_capabilities
284298
tools: { listChanged: true },
285299
prompts: { listChanged: true },
286300
resources: { listChanged: true },
301+
logging: {},
287302
}
288303
end
289304

@@ -307,6 +322,19 @@ def init(request)
307322
}.compact
308323
end
309324

325+
def logging_level=(request)
326+
if capabilities[:logging].nil?
327+
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
328+
end
329+
330+
logging_message_notification = LoggingMessageNotification.new(level: request[:level])
331+
unless logging_message_notification.valid_level?
332+
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
333+
end
334+
335+
@logging_message_notification = logging_message_notification
336+
end
337+
310338
def list_tools(request)
311339
@tools.values.map(&:to_h)
312340
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)