diff --git a/README.md b/README.md index 5ffd8d2..0010291 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,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 @@ -121,6 +122,83 @@ 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` + +### Logging + +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). + +The `notifications/message` notification is used for structured logging between client and server. + +#### Log Levels + +The SDK supports 8 log levels with increasing severity: + +| Level | Severity | Description | +|-------|----------|-------------| +| `debug` | 0 | Detailed debugging information | +| `info` | 1 | General informational messages | +| `notice` | 2 | Normal but significant events | +| `warning` | 3 | Warning conditions | +| `error` | 4 | Error conditions | +| `critical` | 5 | Critical conditions | +| `alert` | 6 | Action must be taken immediately | +| `emergency` | 7 | System is unusable | + +#### How Logging Works + +1. **Client Configuration**: The client sends a `logging/setLevel` request to configure the minimum log level +2. **Server Filtering**: The server only sends log messages at the configured level or higher severity +3. **Notification Delivery**: Log messages are sent as `notifications/message` to the client + +For example, if the client sets the level to `"error"` (severity 4), the server will send messages with levels: `error`, `critical`, `alert`, and `emergency`. + +For more details, see the [MCP Logging specification](https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging). + +**Usage Example:** + +```ruby +server = MCP::Server.new(name: "my_server") +transport = MCP::Server::Transports::StdioTransport.new(server) +server.transport = transport + +# The client first configures the logging level (on the client side): +transport.send_request( + request: { + jsonrpc: "2.0", + method: "logging/setLevel", + params: { level: "info" }, + id: session_id # Unique request ID within the session + } +) + +# Send log messages at different severity levels +server.notify_log_message( + data: { message: "Application started successfully" }, + level: "info" +) + +server.notify_log_message( + data: { message: "Configuration file not found, using defaults" }, + level: "warning" +) + +server.notify_log_message( + data: { + error: "Database connection failed", + details: { host: "localhost", port: 5432 } + }, + level: "error", + logger: "DatabaseLogger" # Optional logger name +) +``` + +**Key Features:** +- 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 +- Server has capability `logging` to send log messages +- Messages are only sent if a transport is configured +- Messages are filtered based on the client's configured log level +- If the log level hasn't been set by the client, no messages will be sent #### Transport Support @@ -141,7 +219,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 b3a7477..de83b23 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -122,6 +122,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 82566fc..4664f19 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -107,6 +107,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/json_rpc_handler.rb b/lib/json_rpc_handler.rb index 309044e..203b456 100644 --- a/lib/json_rpc_handler.rb +++ b/lib/json_rpc_handler.rb @@ -105,6 +105,8 @@ def process_request(request, id_validation_pattern:, &method_finder) result = method.call(params) success_response(id:, result:) + rescue MCP::Server::RequestHandlerError => e + handle_request_error(e, id, id_validation_pattern) rescue StandardError => e error_response(id:, id_validation_pattern:, error: { code: ErrorCode::INTERNAL_ERROR, @@ -114,6 +116,24 @@ def process_request(request, id_validation_pattern:, &method_finder) end end + def handle_request_error(error, id, id_validation_pattern) + error_type = error.respond_to?(:error_type) ? error.error_type : nil + + code, message = case error_type + when :invalid_request then [ErrorCode::INVALID_REQUEST, "Invalid Request"] + when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"] + when :parse_error then [ErrorCode::PARSE_ERROR, "Parse error"] + when :internal_error then [ErrorCode::INTERNAL_ERROR, "Internal error"] + else [ErrorCode::INTERNAL_ERROR, "Internal error"] + end + + error_response(id:, id_validation_pattern:, error: { + code:, + message:, + data: error.message, + }) + end + def valid_version?(version) version == Version::V2_0 end diff --git a/lib/mcp/logging_message_notification.rb b/lib/mcp/logging_message_notification.rb new file mode 100644 index 0000000..a974e07 --- /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_LEVEL_SEVERITY = { + "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_LEVEL_SEVERITY.keys.include?(level) + end + + def should_notify?(log_level) + LOG_LEVEL_SEVERITY[log_level] >= LOG_LEVEL_SEVERITY[level] + end + end +end diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 3a2dd9f..16b551b 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -3,6 +3,7 @@ require_relative "../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", @@ -62,6 +63,7 @@ def initialize( validate! @capabilities = capabilities || default_capabilities + @logging_message_notification = nil @handlers = { Methods::RESOURCES_LIST => method(:list_resources), @@ -74,12 +76,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 @@ -140,6 +142,18 @@ def notify_resources_list_changed report_exception(e, { notification: "resources_list_changed" }) end + def notify_log_message(data:, level:, logger: nil) + return unless @transport + 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 @@ -232,6 +246,7 @@ def default_capabilities tools: { listChanged: true }, prompts: { listChanged: true }, resources: { listChanged: true }, + logging: {}, } end @@ -252,6 +267,19 @@ def init(request) }.compact end + def logging_level=(request) + if capabilities[:logging].nil? + raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error) + end + + 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_params) + end + + @logging_message_notification = logging_message_notification + end + def list_tools(request) @tools.values.map(&:to_h) end diff --git a/test/json_rpc_handler_test.rb b/test/json_rpc_handler_test.rb index 38004e6..c30a34d 100644 --- a/test/json_rpc_handler_test.rb +++ b/test/json_rpc_handler_test.rb @@ -398,6 +398,96 @@ } end + it "returns an error with the code set to -32600 when error_type of RequestHandlerError is :invalid_request" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Invalid request data", + {}, + error_type: :invalid_request, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32600, + message: "Invalid Request", + data: "Invalid request data", + } + end + + it "returns an error with the code set to -32602 when error_type of RequestHandlerError is :invalid_params" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Parameter validation failed", + {}, + error_type: :invalid_params, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32602, + message: "Invalid params", + data: "Parameter validation failed", + } + end + + it "returns an error with the code set to -32700 when error_type of RequestHandlerError is :parse_error" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Failed to parse input", + {}, + error_type: :parse_error, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32700, + message: "Parse error", + data: "Failed to parse input", + } + end + + it "returns an error with the code set to -32603 when error_type of RequestHandlerError is :internal_error" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Internal processing error", + {}, + error_type: :internal_error, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32603, + message: "Internal error", + data: "Internal processing error", + } + end + + it "returns an error with the code set to -32603 when error_type of RequestHandlerError is unknown" do + register("test_method") do + raise MCP::Server::RequestHandlerError.new( + "Unknown error occurred", + {}, + error_type: :unknown, + ) + end + + handle jsonrpc: "2.0", id: 1, method: "test_method" + + assert_rpc_error expected_error: { + code: -32603, + message: "Internal error", + data: "Unknown error occurred", + } + end + # 6 Batch # # To send several Request objects at the same time, the Client MAY send an Array filled with Request objects. diff --git a/test/mcp/logging_message_notification_test.rb b/test/mcp/logging_message_notification_test.rb new file mode 100644 index 0000000..0a111f9 --- /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_LEVEL_SEVERITY.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 6030e4b..fd4defd 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 716b167..3b958ce 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 3b4ebf0..b5fa8ef 100644 --- a/test/mcp/server_notification_test.rb +++ b/test/mcp/server_notification_test.rb @@ -66,14 +66,58 @@ 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 + assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications.first[:method] + assert_equal({ data: { error: "Connection Failed" }, level: "error" }, @mock_transport.notifications.first[: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 "#notify_log_message does not send notification with invalid log level" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { message: "test" }, level: "invalid") + + assert_equal 0, @mock_transport.notifications.size + end + + test "#notify_log_message does not send notification when level is below configured level" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { message: "test" }, level: "info") + + assert_equal 0, @mock_transport.notifications.size + end + + test "#notify_log_message sends notification when level is above configured level through transport" do + @server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error") + @server.notify_log_message(data: { message: "test" }, level: "critical") + + assert_equal 1, @mock_transport.notifications.size + assert_equal Methods::NOTIFICATIONS_MESSAGE, @mock_transport.notifications[0][:method] + assert_equal({ data: { message: "test" }, level: "critical" }, @mock_transport.notifications[0][: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 @@ -86,16 +130,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 @@ -108,22 +154,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 2f2d188..cd9bdd5 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, @@ -674,6 +675,57 @@ def call(message:, server_context: nil) assert_instrumentation_data({ method: "resources/templates/list" }) end + test "#logging_level= returns an error object 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 + + test "#logging_level= returns an error object when server has not logging capability" do + server = Server.new( + tools: [TestTool], + configuration: Configuration.new(validate_tool_call_arguments: true), + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { listChanged: true }, + }, + ) + + response = server.handle( + { + jsonrpc: "2.0", + id: 1, + method: "logging/setLevel", + params: { + level: "debug", + }, + }, + ) + + assert_equal "2.0", response[:jsonrpc] + assert_equal 1, response[:id] + assert_equal(-32603, response[:error][:code]) + assert_includes response[:error][:data], "Server does not support logging" + end + test "#handle method with missing required top-level capability returns an error" do @server.capabilities = {}