diff --git a/README.md b/README.md index 59e297b..16c0e57 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_logging_message()` - Send a structured logging notification message #### Notification Format @@ -119,6 +120,7 @@ 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` #### Transport Support @@ -139,7 +141,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 a965594..2b75bb2 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 b61fe06..7b5f99b 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_logging_message(data: { details: "Response accepted and sent via SSE" }) 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 0000000..aa94a34 --- /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", "info", "notice", "warning", "error", "critical", "alert", "emergency"].freeze + attr_reader :level + + class InvalidLevelError < StandardError + def initialize + super("Invalid log level provided. Valid levels are: #{LOG_LEVELS.join(", ")}") + @code = JsonRpcHandler::ErrorCode::InvalidParams + end + end + + class NotSpecifiedLevelError < StandardError + def initialize + super("Log level not specified. Please set a valid log level.") + @code = JsonRpcHandler::ErrorCode::InternalError + end + end + + def initialize(level:) + @level = level + end + + def valid_level? + LOG_LEVELS.include?(level) + end + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index e6c2684..34380b8 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, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport + attr_accessor :name, :version, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification def initialize( name: "model_context_protocol", @@ -63,6 +64,7 @@ def initialize( end @capabilities = capabilities || default_capabilities + @logging_message_notification = nil @handlers = { Methods::RESOURCES_LIST => method(:list_resources), @@ -74,12 +76,12 @@ def initialize( Methods::PROMPTS_GET => method(:get_prompt), Methods::INITIALIZE => method(:init), Methods::PING => ->(_) { {} }, + 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 @@ -138,6 +140,19 @@ def notify_resources_list_changed report_exception(e, { notification: "resources_list_changed" }) end + def notify_logging_message(logger: nil, data: nil) + return unless @transport + raise LoggingMessageNotification::NotSpecifiedLevelError unless logging_message_notification&.level + + params = { level: logging_message_notification.level } + params[:logger] = logger if logger + params[:data] = data if data + + @transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params) + rescue => e + report_exception(e, { notification: "logging_message_notification" }) + end + def resources_list_handler(&block) @handlers[Methods::RESOURCES_LIST] = block end @@ -211,6 +226,7 @@ def default_capabilities tools: { listChanged: true }, prompts: { listChanged: true }, resources: { listChanged: true }, + logging: {}, } end @@ -230,6 +246,13 @@ def init(request) }.compact end + def logging_level=(level) + logging_message_notification = LoggingMessageNotification.new(level: level) + raise LoggingMessageNotification::InvalidLevelError unless logging_message_notification.valid_level? + + @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 0000000..d4c6266 --- /dev/null +++ b/test/mcp/logging_message_notification_test.rb @@ -0,0 +1,47 @@ +# 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.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) + assert_not logging_message_notification.valid_level?, "#{level} should be invalid" + end + end + + test "InvalidLevelError has correct error code" do + error = LoggingMessageNotification::InvalidLevelError.new + assert_equal(-32602, error.instance_variable_get(:@code)) + end + + test "InvalidLevelError message format" do + error = LoggingMessageNotification::InvalidLevelError.new + expected_levels = LoggingMessageNotification::LOG_LEVELS.join(", ") + expected_message = "Invalid log level provided. Valid levels are: #{expected_levels}" + + assert_equal expected_message, error.message + end + + test "NotSpecifiedLevelError has correct error code" do + error = LoggingMessageNotification::NotSpecifiedLevelError.new + assert_equal(-32603, error.instance_variable_get(:@code)) + end + + test "NotSpecifiedLevelError has correct message" do + error = LoggingMessageNotification::NotSpecifiedLevelError.new + expected_message = "Log level not specified. Please set a valid log level." + + assert_equal expected_message, error.message + 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 eb4947c..6cf06e1 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_logging_message(data: { error: "Connection Failed" }) + # 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_logging_message(data: { error: "Connection Failed" }) end end @@ -239,6 +248,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_logging_message(data: { error: "Connection Failed" }) + 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({ "level" => "error", "data" => { "error" => "Connection Failed" } }, 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 716b167..54b27bd 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_logging_message(data: { error: "Connection Failed" }) + # 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\":{\"level\":\"error\",\"data\":{\"error\":\"Connection Failed\"}}}\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_logging_message(data: { error: "Connection Failed" }) end end diff --git a/test/mcp/server_notification_test.rb b/test/mcp/server_notification_test.rb index af35936..22c7a8a 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -67,14 +67,26 @@ def handle_request(request); end assert_nil notification[:params] end + test "#notify_logging_message sends notification through transport" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_logging_message(data: { error: "Connection Failed" }) + + assert_equal 1, @mock_transport.notifications.size + notification = @mock_transport.notifications.first + assert_equal Methods::NOTIFICATIONS_MESSAGE, notification[:method] + assert_equal notification[:params], { level: "error", data: { error: "Connection Failed" } } + 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_logging_message(data: { error: "Connection Failed" }) end end @@ -87,16 +99,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: "logging_message_notification" }, ] 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 +123,26 @@ def send_notification(method, params = nil) @server.notify_tools_list_changed @server.notify_prompts_list_changed @server.notify_resources_list_changed + @server.notify_logging_message(data: { error: "Connection Failed" }) 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_logging_message(data: { error: "Connection Failed" }) - 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 2346bd7..b4832e2 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -129,6 +129,7 @@ class ServerTest < ActiveSupport::TestCase prompts: { listChanged: true }, resources: { listChanged: true }, tools: { listChanged: true }, + logging: {}, }, serverInfo: { name: @server_name,