diff --git a/README.md b/README.md index 834280d0..d85b846c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. + +##### Usage Example + +```ruby +# Client sets logging level +# Request: { "jsonrpc": "2.0", "method": "logging/setLevel", "params": { "level": "error" } } + +# Server sends notifications for log events +server.notify_log_message( + data: { error: "Connection Failed" }, + level: "error", + logger: "DatabaseLogger" +) +``` #### Transport Support @@ -139,7 +162,6 @@ server.notify_tools_list_changed ### Unsupported Features ( to be implemented in future versions ) -- Log Level - Resource subscriptions - Completions diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index a965594d..2b75bb24 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -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"]}") diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index b61fe066..4f3186d8 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -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"]})") diff --git a/lib/mcp/logging_message_notification.rb b/lib/mcp/logging_message_notification.rb new file mode 100644 index 00000000..5833dfd0 --- /dev/null +++ b/lib/mcp/logging_message_notification.rb @@ -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 + + 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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index f62ee03b..7c3c67c7 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -3,6 +3,7 @@ require "json_rpc_handler" require_relative "instrumentation" require_relative "methods" +require_relative "logging_message_notification" module MCP class Server @@ -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", @@ -65,6 +66,7 @@ def initialize( end @capabilities = capabilities || default_capabilities + @logging_message_notification = nil @handlers = { Methods::RESOURCES_LIST => method(:list_resources), @@ -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 @@ -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 + + @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 @@ -214,6 +231,7 @@ def default_capabilities tools: { listChanged: true }, prompts: { listChanged: true }, resources: { listChanged: true }, + logging: {}, } end @@ -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 diff --git a/test/mcp/logging_message_notification_test.rb b/test/mcp/logging_message_notification_test.rb new file mode 100644 index 00000000..fc7c0b17 --- /dev/null +++ b/test/mcp/logging_message_notification_test.rb @@ -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 diff --git a/test/mcp/server/transports/stdio_notification_integration_test.rb b/test/mcp/server/transports/stdio_notification_integration_test.rb index 745aa4ad..a75049a9 100644 --- a/test/mcp/server/transports/stdio_notification_integration_test.rb +++ b/test/mcp/server/transports/stdio_notification_integration_test.rb @@ -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) } @@ -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 @@ -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 @@ -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 diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb index 716b167e..3b958cef 100644 --- a/test/mcp/server/transports/streamable_http_notification_integration_test.rb +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -51,6 +51,12 @@ 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 @@ -58,6 +64,7 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase 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 @@ -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 diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index af35936d..1d062fc0 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -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 @@ -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 @@ -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 diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index a8527bc0..9e363161 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -138,6 +138,7 @@ class ServerTest < ActiveSupport::TestCase prompts: { listChanged: true }, resources: { listChanged: true }, tools: { listChanged: true }, + logging: {}, }, serverInfo: { name: @server_name, @@ -1062,5 +1063,51 @@ def call(numbers:, strings:, objects:, server_context: nil) end end end + + test "#notify_log_message raises RequestHandlerError when logging_message_notification is null" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + method: "notifications/message", + params: { + level: "info", + data: "test message", + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_equal(-32603, response[:error][:code]) + assert_includes response[:error][:data], "logging_message_notification must not be null" + end + + test "#logging_level= raises RequestHandlerError when invalid log level is provided" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "logging/setLevel", + params: { + level: "invalid_level", + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_equal(-32602, response[:error][:code]) + assert_includes response[:error][:data], "Invalid log level invalid_level" + end end end