Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ The server provides three notification methods:
- `notify_tools_list_changed` - Send a notification when the tools list changes
- `notify_prompts_list_changed` - Send a notification when the prompts list changes
- `notify_resources_list_changed` - Send a notification when the resources list changes
- `notify_log_message` - Send a structured logging notification message

#### Notification Format

Expand All @@ -119,6 +120,28 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
- `notifications/tools/list_changed`
- `notifications/prompts/list_changed`
- `notifications/resources/list_changed`
- `notifications/message`

#### Notification Logging Message Flow

The `notifications/message` notification is used for structured logging between client and server.

1. **Client sends logging configuration**: The client first sends a `logging/setLevel` request to configure the desired log level.
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.
Copy link
Member

@koic koic Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to provide an explanation of the Ruby API as part of the Ruby SDK. As for the MCP specification, I think it would be helpful to include a link to https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging.


##### Usage Example
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
##### Usage Example
**Usage Example:**


```ruby
# Client sets logging level
# Request: { "jsonrpc": "2.0", "method": "logging/setLevel", "params": { "level": "error" } }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to show how to set the logging level in the Ruby SDK with Ruby code.


# Server sends notifications for log events
server.notify_log_message(
data: { error: "Connection Failed" },
level: "error",
logger: "DatabaseLogger"
)
```

#### Transport Support

Expand All @@ -139,7 +162,6 @@ server.notify_tools_list_changed

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

- Log Level
- Resource subscriptions
- Completions

Expand Down
4 changes: 4 additions & 0 deletions examples/streamable_http_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ def main
exit(1)
end

if init_response[:body].dig("result", "capabilities", "logging")
make_request(session_id, "logging/setLevel", { level: "info" })
end

logger.info("Session initialized: #{session_id}")
logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")

Expand Down
1 change: 1 addition & 0 deletions examples/streamable_http_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def call(message:, delay: 0)
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
elsif parsed_response["accepted"]
# Response was sent via SSE
server.notify_log_message(data: { details: "Response accepted and sent via SSE" }, level: "info")
sse_logger.info("Response sent via SSE stream")
else
mcp_logger.info("Response: success (id: #{parsed_response["id"]})")
Expand Down
32 changes: 32 additions & 0 deletions lib/mcp/logging_message_notification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

require "json_rpc_handler"

module MCP
class LoggingMessageNotification
LOG_LEVELS = {
"debug" => 0,
"info" => 1,
"notice" => 2,
"warning" => 3,
"error" => 4,
"critical" => 5,
"alert" => 6,
"emergency" => 7,
}.freeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intuitively I think debug with the lowest level of information should be ordered as 0 and emergency with the highest level of information as 7. In other words, the keys are in reverse order.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same thought, but I prioritized meeting the specifications of RFC 5424's Numerical Code explicitly.
However, looking at the java-sdk, it seems to be defined in reverse order.
Since that order is intuitive and easy to understand, I made the same correction.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems my explanation was not clear. What is actually expected is the following.

    LOG_LEVELS = {
      "debug" => 0,
      "info" => 1,
      "notice" => 2,
      "warning" => 3,
      "error" => 4,
      "critical" => 5,
      "alert" => 6,
      "emergency" => 7,
    }.freeze

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's more intuitive. Fixed.


private attr_reader :level

def initialize(level:)
@level = level
end

def valid_level?
LOG_LEVELS.keys.include?(level)
end

def should_notify?(log_level)
LOG_LEVELS[log_level] >= LOG_LEVELS[level]
end
end
end
31 changes: 29 additions & 2 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "json_rpc_handler"
require_relative "instrumentation"
require_relative "methods"
require_relative "logging_message_notification"

module MCP
class Server
Expand Down Expand Up @@ -31,7 +32,7 @@ def initialize(method_name)

include Instrumentation

attr_accessor :name, :title, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
attr_accessor :name, :title, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification

def initialize(
name: "model_context_protocol",
Expand Down Expand Up @@ -65,6 +66,7 @@ def initialize(
end

@capabilities = capabilities || default_capabilities
@logging_message_notification = nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, the Python SDK uses "info" level as the default. What do you think about doing the same?
https://github.com/modelcontextprotocol/python-sdk/blob/v1.12.3/src/mcp/server/fastmcp/server.py#L132

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's necessary to set a default value, but what do you think?

The log_level literal specified in the MCP spec appears to be defined in mcp/types.py, and it seems that no default value has been set.

https://github.com/modelcontextprotocol/python-sdk/blob/68e25d478b3b6a026b2d9a30b3e5f34f3b1290de/src/mcp/types.py#L905

The log_level in fastmcp/server.py#L132 appears to set the default value for uvicorn's log_level.

However, if this literal is the same as the one specified in the MCP spec, I don't think it meets the logging specifications, as levels such as emergency and notice are not defined.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true. There's no need to set something that's not explicitly specified in the specification.
Your current suggestion makes sense to me.


@handlers = {
Methods::RESOURCES_LIST => method(:list_resources),
Expand All @@ -77,12 +79,12 @@ def initialize(
Methods::INITIALIZE => method(:init),
Methods::PING => ->(_) { {} },
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
Methods::LOGGING_SET_LEVEL => method(:logging_level=),

# No op handlers for currently unsupported methods
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
Methods::COMPLETION_COMPLETE => ->(_) {},
Methods::LOGGING_SET_LEVEL => ->(_) {},
}
@transport = transport
end
Expand Down Expand Up @@ -141,6 +143,21 @@ def notify_resources_list_changed
report_exception(e, { notification: "resources_list_changed" })
end

def notify_log_message(data:, level:, logger: nil)
return unless @transport
unless logging_message_notification
raise RequestHandlerError.new("logging_message_notification must not be null", {}, error_type: :logging_message_notification_not_specified)
end
return unless logging_message_notification.should_notify?(level)

params = { data:, level: }
params[:logger] = logger if logger
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems there are no test cases for the logger keyword. Can you add it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you said, I couldn't test it. Added.


@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
rescue => e
report_exception(e, { notification: "log_message" })
end

def resources_list_handler(&block)
@handlers[Methods::RESOURCES_LIST] = block
end
Expand Down Expand Up @@ -214,6 +231,7 @@ def default_capabilities
tools: { listChanged: true },
prompts: { listChanged: true },
resources: { listChanged: true },
logging: {},
}
end

Expand All @@ -234,6 +252,15 @@ def init(request)
}.compact
end

def logging_level=(request)
logging_message_notification = LoggingMessageNotification.new(level: request[:level])
unless logging_message_notification.valid_level?
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_log_level)
end

@logging_message_notification = logging_message_notification
end

def list_tools(request)
@tools.map { |_, tool| tool.to_h }
end
Expand Down
38 changes: 38 additions & 0 deletions test/mcp/logging_message_notification_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

require "test_helper"

module MCP
class LoggingMessageNotificationTest < ActiveSupport::TestCase
test "valid_level? returns true for valid levels" do
LoggingMessageNotification::LOG_LEVELS.keys.each do |level|
logging_message_notification = LoggingMessageNotification.new(level: level)
assert logging_message_notification.valid_level?, "#{level} should be valid"
end
end

test "valid_level? returns false for invalid levels" do
invalid_levels = ["invalid", 1, "", nil, :fatal]
invalid_levels.each do |level|
logging_message_notification = LoggingMessageNotification.new(level: level)
refute logging_message_notification.valid_level?, "#{level} should be invalid"
end
end

test "should_notify? returns true when notification level is higher priority than threshold level or equals to it" do
logging_message_notification = LoggingMessageNotification.new(level: "warning")
assert logging_message_notification.should_notify?("warning")
assert logging_message_notification.should_notify?("error")
assert logging_message_notification.should_notify?("critical")
assert logging_message_notification.should_notify?("alert")
assert logging_message_notification.should_notify?("emergency")
end

test "should_notify? returns false when notification level is lower priority than threshold level" do
logging_message_notification = LoggingMessageNotification.new(level: "warning")
refute logging_message_notification.should_notify?("notice")
refute logging_message_notification.should_notify?("info")
refute logging_message_notification.should_notify?("debug")
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ def closed?
# Test resources notification
@server.notify_resources_list_changed

# Test log notification
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")

# Check the notifications were sent
assert_equal 3, @mock_stdout.output.size
assert_equal 4, @mock_stdout.output.size

# Parse and verify each notification
notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) }
Expand All @@ -96,6 +100,10 @@ def closed?
assert_equal "2.0", notifications[2]["jsonrpc"]
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2]["method"]
assert_nil notifications[2]["params"]

assert_equal "2.0", notifications[3]["jsonrpc"]
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3]["method"]
assert_equal({ "level" => "error", "data" => { "error" => "Connection Failed" } }, notifications[3]["params"])
end

test "notifications include params when provided" do
Expand All @@ -120,6 +128,7 @@ def closed?
@server.notify_tools_list_changed
@server.notify_prompts_list_changed
@server.notify_resources_list_changed
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
end
end

Expand Down Expand Up @@ -240,6 +249,16 @@ def puts(message)
assert_equal 2, @mock_stdout.output.size
second_notification = JSON.parse(@mock_stdout.output.last)
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, second_notification["method"]

# Set log level and notify
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")

# Manually trigger notification
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
assert_equal 3, @mock_stdout.output.size
third_notification = JSON.parse(@mock_stdout.output.last)
assert_equal Methods::NOTIFICATIONS_MESSAGE, third_notification["method"]
assert_equal({ "data" => { "error" => "Connection Failed" }, "level" => "error" }, third_notification["params"])
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,20 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
# Test resources notification
@server.notify_resources_list_changed

# Set log level to error for log notification
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")

# Test log notification
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")

# Check the notifications were received
io.rewind
output = io.read

assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}\"}"
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED}\"}"
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED}\"}"
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_MESSAGE}\",\"params\":{\"data\":{\"error\":\"Connection Failed\"},\"level\":\"error\"}}\n\n"
end

test "notifications are broadcast to all connected sessions" do
Expand Down Expand Up @@ -147,6 +154,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
@server.notify_tools_list_changed
@server.notify_prompts_list_changed
@server.notify_resources_list_changed
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
end
end

Expand Down
34 changes: 31 additions & 3 deletions test/mcp/server_notification_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,36 @@ def handle_request(request); end
assert_nil notification[:params]
end

test "#notify_log_message sends notification through transport" do
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")

assert_equal 1, @mock_transport.notifications.size
notification = @mock_transport.notifications.first
assert_equal Methods::NOTIFICATIONS_MESSAGE, notification[:method]
assert_equal({ data: { error: "Connection Failed" }, level: "error" }, notification[:params])
end

test "#notify_log_message sends notification with logger through transport" do
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger")

assert_equal 1, @mock_transport.notifications.size
notification = @mock_transport.notifications.first
assert_equal Methods::NOTIFICATIONS_MESSAGE, notification[:method]
assert_equal({ data: { error: "Connection Failed" }, level: "error", logger: "DatabaseLogger" }, notification[:params])
end

test "notification methods work without transport" do
server_without_transport = Server.new(name: "test_server")
server_without_transport.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")

# Should not raise any errors
assert_nothing_raised do
server_without_transport.notify_tools_list_changed
server_without_transport.notify_prompts_list_changed
server_without_transport.notify_resources_list_changed
server_without_transport.notify_log_message(data: { error: "Connection Failed" }, level: "error")
end
end

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

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

# Mock the exception reporter
expected_contexts = [
{ notification: "tools_list_changed" },
{ notification: "prompts_list_changed" },
{ notification: "resources_list_changed" },
{ notification: "log_message" },
]

call_count = 0
@server.configuration.exception_reporter.expects(:call).times(3).with do |exception, context|
@server.configuration.exception_reporter.expects(:call).times(4).with do |exception, context|
assert_kind_of StandardError, exception
assert_equal "Transport error", exception.message
assert_includes expected_contexts, context
Expand All @@ -109,22 +133,26 @@ def send_notification(method, params = nil)
@server.notify_tools_list_changed
@server.notify_prompts_list_changed
@server.notify_resources_list_changed
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
end

assert_equal 3, call_count
assert_equal 4, call_count
end

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

assert_equal 3, @mock_transport.notifications.size
assert_equal 4, @mock_transport.notifications.size

notifications = @mock_transport.notifications
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method]
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][:method]
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2][:method]
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method]
end
end
end
Loading
Loading