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.
This commit is contained in:
parent
830913be5a
commit
afa2fb4077
12 changed files with 434 additions and 7 deletions
78
README.md
78
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_tools_list_changed` - Send a notification when the tools list changes
|
||||||
- `notify_prompts_list_changed` - Send a notification when the prompts 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_resources_list_changed` - Send a notification when the resources list changes
|
||||||
|
- `notify_log_message` - Send a structured logging notification message
|
||||||
|
|
||||||
#### Notification Format
|
#### Notification Format
|
||||||
|
|
||||||
|
|
@ -121,6 +122,82 @@ Notifications follow the JSON-RPC 2.0 specification and use these method names:
|
||||||
- `notifications/tools/list_changed`
|
- `notifications/tools/list_changed`
|
||||||
- `notifications/prompts/list_changed`
|
- `notifications/prompts/list_changed`
|
||||||
- `notifications/resources/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/latest/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:
|
||||||
|
|
||||||
|
- `debug` - Detailed debugging information
|
||||||
|
- `info` - General informational messages
|
||||||
|
- `notice` - Normal but significant events
|
||||||
|
- `warning` - Warning conditions
|
||||||
|
- `error` - Error conditions
|
||||||
|
- `critical` - Critical conditions
|
||||||
|
- `alert` - Action must be taken immediately
|
||||||
|
- `emergency` - 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/latest/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
|
#### Transport Support
|
||||||
|
|
||||||
|
|
@ -153,7 +230,6 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, statele
|
||||||
|
|
||||||
### Unsupported Features (to be implemented in future versions)
|
### Unsupported Features (to be implemented in future versions)
|
||||||
|
|
||||||
- Log Level
|
|
||||||
- Resource subscriptions
|
- Resource subscriptions
|
||||||
- Completions
|
- Completions
|
||||||
- Elicitation
|
- Elicitation
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,10 @@ def main
|
||||||
exit(1)
|
exit(1)
|
||||||
end
|
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("Session initialized: #{session_id}")
|
||||||
logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")
|
logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ app = proc do |env|
|
||||||
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
|
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
|
||||||
elsif parsed_response["accepted"]
|
elsif parsed_response["accepted"]
|
||||||
# Response was sent via SSE
|
# 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")
|
sse_logger.info("Response sent via SSE stream")
|
||||||
else
|
else
|
||||||
mcp_logger.info("Response: success (id: #{parsed_response["id"]})")
|
mcp_logger.info("Response: success (id: #{parsed_response["id"]})")
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ module JsonRpcHandler
|
||||||
result = method.call(params)
|
result = method.call(params)
|
||||||
|
|
||||||
success_response(id: id, result: result)
|
success_response(id: id, result: result)
|
||||||
|
rescue MCP::Server::RequestHandlerError => e
|
||||||
|
handle_request_error(e, id, id_validation_pattern)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
|
error_response(id: id, id_validation_pattern: id_validation_pattern, error: {
|
||||||
code: ErrorCode::INTERNAL_ERROR,
|
code: ErrorCode::INTERNAL_ERROR,
|
||||||
|
|
@ -114,6 +116,24 @@ module JsonRpcHandler
|
||||||
end
|
end
|
||||||
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, id_validation_pattern: id_validation_pattern, error: {
|
||||||
|
code: code,
|
||||||
|
message: message,
|
||||||
|
data: error.message,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
def valid_version?(version)
|
def valid_version?(version)
|
||||||
version == Version::V2_0
|
version == Version::V2_0
|
||||||
end
|
end
|
||||||
|
|
|
||||||
32
lib/mcp/logging_message_notification.rb
Normal file
32
lib/mcp/logging_message_notification.rb
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|
def initialize(level:)
|
||||||
|
@level = level
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_level?
|
||||||
|
LOG_LEVEL_SEVERITY.keys.include?(@level)
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_notify?(log_level)
|
||||||
|
return false unless LOG_LEVEL_SEVERITY.key?(log_level)
|
||||||
|
|
||||||
|
LOG_LEVEL_SEVERITY[log_level] >= LOG_LEVEL_SEVERITY[@level]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
require_relative "../json_rpc_handler"
|
require_relative "../json_rpc_handler"
|
||||||
require_relative "instrumentation"
|
require_relative "instrumentation"
|
||||||
require_relative "methods"
|
require_relative "methods"
|
||||||
|
require_relative "logging_message_notification"
|
||||||
|
|
||||||
module MCP
|
module MCP
|
||||||
class ToolNotUnique < StandardError
|
class ToolNotUnique < StandardError
|
||||||
|
|
@ -40,7 +41,7 @@ module MCP
|
||||||
|
|
||||||
include Instrumentation
|
include Instrumentation
|
||||||
|
|
||||||
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport
|
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
||||||
|
|
||||||
def initialize(
|
def initialize(
|
||||||
description: nil,
|
description: nil,
|
||||||
|
|
@ -78,6 +79,7 @@ module MCP
|
||||||
validate!
|
validate!
|
||||||
|
|
||||||
@capabilities = capabilities || default_capabilities
|
@capabilities = capabilities || default_capabilities
|
||||||
|
@logging_message_notification = nil
|
||||||
|
|
||||||
@handlers = {
|
@handlers = {
|
||||||
Methods::RESOURCES_LIST => method(:list_resources),
|
Methods::RESOURCES_LIST => method(:list_resources),
|
||||||
|
|
@ -90,12 +92,12 @@ module MCP
|
||||||
Methods::INITIALIZE => method(:init),
|
Methods::INITIALIZE => method(:init),
|
||||||
Methods::PING => ->(_) { {} },
|
Methods::PING => ->(_) { {} },
|
||||||
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
|
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
|
||||||
|
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
|
||||||
|
|
||||||
# No op handlers for currently unsupported methods
|
# No op handlers for currently unsupported methods
|
||||||
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
|
Methods::RESOURCES_SUBSCRIBE => ->(_) {},
|
||||||
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
|
Methods::RESOURCES_UNSUBSCRIBE => ->(_) {},
|
||||||
Methods::COMPLETION_COMPLETE => ->(_) {},
|
Methods::COMPLETION_COMPLETE => ->(_) {},
|
||||||
Methods::LOGGING_SET_LEVEL => ->(_) {},
|
|
||||||
Methods::ELICITATION_CREATE => ->(_) {},
|
Methods::ELICITATION_CREATE => ->(_) {},
|
||||||
}
|
}
|
||||||
@transport = transport
|
@transport = transport
|
||||||
|
|
@ -162,6 +164,18 @@ module MCP
|
||||||
report_exception(e, { notification: "resources_list_changed" })
|
report_exception(e, { notification: "resources_list_changed" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def notify_log_message(data:, level:, logger: nil)
|
||||||
|
return unless @transport
|
||||||
|
return unless logging_message_notification&.should_notify?(level)
|
||||||
|
|
||||||
|
params = { "data" => data, "level" => 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)
|
def resources_list_handler(&block)
|
||||||
@handlers[Methods::RESOURCES_LIST] = block
|
@handlers[Methods::RESOURCES_LIST] = block
|
||||||
end
|
end
|
||||||
|
|
@ -284,6 +298,7 @@ module MCP
|
||||||
tools: { listChanged: true },
|
tools: { listChanged: true },
|
||||||
prompts: { listChanged: true },
|
prompts: { listChanged: true },
|
||||||
resources: { listChanged: true },
|
resources: { listChanged: true },
|
||||||
|
logging: {},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -307,6 +322,19 @@ module MCP
|
||||||
}.compact
|
}.compact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def configure_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)
|
def list_tools(request)
|
||||||
@tools.values.map(&:to_h)
|
@tools.values.map(&:to_h)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,96 @@ describe JsonRpcHandler do
|
||||||
}
|
}
|
||||||
end
|
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
|
# 6 Batch
|
||||||
#
|
#
|
||||||
# To send several Request objects at the same time, the Client MAY send an Array filled with Request objects.
|
# To send several Request objects at the same time, the Client MAY send an Array filled with Request objects.
|
||||||
|
|
|
||||||
47
test/mcp/logging_message_notification_test.rb
Normal file
47
test/mcp/logging_message_notification_test.rb
Normal file
|
|
@ -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_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
|
||||||
|
|
||||||
|
test "should_notify? returns false for invalid log levels" do
|
||||||
|
logging_message_notification = LoggingMessageNotification.new(level: "warning")
|
||||||
|
refute logging_message_notification.should_notify?("invalid")
|
||||||
|
refute logging_message_notification.should_notify?(1)
|
||||||
|
refute logging_message_notification.should_notify?("")
|
||||||
|
refute logging_message_notification.should_notify?(nil)
|
||||||
|
refute logging_message_notification.should_notify?(:fatal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -79,8 +79,12 @@ module MCP
|
||||||
# Test resources notification
|
# Test resources notification
|
||||||
@server.notify_resources_list_changed
|
@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
|
# Check the notifications were sent
|
||||||
assert_equal 3, @mock_stdout.output.size
|
assert_equal 4, @mock_stdout.output.size
|
||||||
|
|
||||||
# Parse and verify each notification
|
# Parse and verify each notification
|
||||||
notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) }
|
notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) }
|
||||||
|
|
@ -96,6 +100,10 @@ module MCP
|
||||||
assert_equal "2.0", notifications[2]["jsonrpc"]
|
assert_equal "2.0", notifications[2]["jsonrpc"]
|
||||||
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2]["method"]
|
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, notifications[2]["method"]
|
||||||
assert_nil notifications[2]["params"]
|
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
|
end
|
||||||
|
|
||||||
test "notifications include params when provided" do
|
test "notifications include params when provided" do
|
||||||
|
|
@ -120,6 +128,7 @@ module MCP
|
||||||
@server.notify_tools_list_changed
|
@server.notify_tools_list_changed
|
||||||
@server.notify_prompts_list_changed
|
@server.notify_prompts_list_changed
|
||||||
@server.notify_resources_list_changed
|
@server.notify_resources_list_changed
|
||||||
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -240,6 +249,16 @@ module MCP
|
||||||
assert_equal 2, @mock_stdout.output.size
|
assert_equal 2, @mock_stdout.output.size
|
||||||
second_notification = JSON.parse(@mock_stdout.output.last)
|
second_notification = JSON.parse(@mock_stdout.output.last)
|
||||||
assert_equal Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED, second_notification["method"]
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,12 @@ module MCP
|
||||||
# Test resources notification
|
# Test resources notification
|
||||||
@server.notify_resources_list_changed
|
@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
|
# Check the notifications were received
|
||||||
io.rewind
|
io.rewind
|
||||||
output = io.read
|
output = io.read
|
||||||
|
|
@ -58,6 +64,7 @@ module MCP
|
||||||
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED}\"}"
|
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_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_RESOURCES_LIST_CHANGED}\"}"
|
||||||
|
assert_includes output, "data: {\"jsonrpc\":\"2.0\",\"method\":\"#{Methods::NOTIFICATIONS_MESSAGE}\",\"params\":{\"data\":{\"error\":\"Connection Failed\"},\"level\":\"error\"}}\n\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "notifications are broadcast to all connected sessions" do
|
test "notifications are broadcast to all connected sessions" do
|
||||||
|
|
@ -147,6 +154,7 @@ module MCP
|
||||||
@server.notify_tools_list_changed
|
@server.notify_tools_list_changed
|
||||||
@server.notify_prompts_list_changed
|
@server.notify_prompts_list_changed
|
||||||
@server.notify_resources_list_changed
|
@server.notify_resources_list_changed
|
||||||
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,58 @@ module MCP
|
||||||
assert_nil notification[:params]
|
assert_nil notification[:params]
|
||||||
end
|
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
|
test "notification methods work without transport" do
|
||||||
server_without_transport = Server.new(name: "test_server")
|
server_without_transport = Server.new(name: "test_server")
|
||||||
|
server_without_transport.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
||||||
|
|
||||||
# Should not raise any errors
|
# Should not raise any errors
|
||||||
assert_nothing_raised do
|
assert_nothing_raised do
|
||||||
server_without_transport.notify_tools_list_changed
|
server_without_transport.notify_tools_list_changed
|
||||||
server_without_transport.notify_prompts_list_changed
|
server_without_transport.notify_prompts_list_changed
|
||||||
server_without_transport.notify_resources_list_changed
|
server_without_transport.notify_resources_list_changed
|
||||||
|
server_without_transport.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -86,16 +130,18 @@ module MCP
|
||||||
end.new(@server)
|
end.new(@server)
|
||||||
|
|
||||||
@server.transport = error_transport
|
@server.transport = error_transport
|
||||||
|
@server.logging_message_notification = MCP::LoggingMessageNotification.new(level: "error")
|
||||||
|
|
||||||
# Mock the exception reporter
|
# Mock the exception reporter
|
||||||
expected_contexts = [
|
expected_contexts = [
|
||||||
{ notification: "tools_list_changed" },
|
{ notification: "tools_list_changed" },
|
||||||
{ notification: "prompts_list_changed" },
|
{ notification: "prompts_list_changed" },
|
||||||
{ notification: "resources_list_changed" },
|
{ notification: "resources_list_changed" },
|
||||||
|
{ notification: "log_message" },
|
||||||
]
|
]
|
||||||
|
|
||||||
call_count = 0
|
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_kind_of StandardError, exception
|
||||||
assert_equal "Transport error", exception.message
|
assert_equal "Transport error", exception.message
|
||||||
assert_includes expected_contexts, context
|
assert_includes expected_contexts, context
|
||||||
|
|
@ -108,22 +154,26 @@ module MCP
|
||||||
@server.notify_tools_list_changed
|
@server.notify_tools_list_changed
|
||||||
@server.notify_prompts_list_changed
|
@server.notify_prompts_list_changed
|
||||||
@server.notify_resources_list_changed
|
@server.notify_resources_list_changed
|
||||||
|
@server.notify_log_message(data: { error: "Connection Failed" }, level: "error")
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal 3, call_count
|
assert_equal 4, call_count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple notification methods can be called in sequence" do
|
test "multiple notification methods can be called in sequence" do
|
||||||
@server.notify_tools_list_changed
|
@server.notify_tools_list_changed
|
||||||
@server.notify_prompts_list_changed
|
@server.notify_prompts_list_changed
|
||||||
@server.notify_resources_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
|
notifications = @mock_transport.notifications
|
||||||
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method]
|
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0][:method]
|
||||||
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1][: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_RESOURCES_LIST_CHANGED, notifications[2][:method]
|
||||||
|
assert_equal Methods::NOTIFICATIONS_MESSAGE, notifications[3][:method]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ module MCP
|
||||||
prompts: { listChanged: true },
|
prompts: { listChanged: true },
|
||||||
resources: { listChanged: true },
|
resources: { listChanged: true },
|
||||||
tools: { listChanged: true },
|
tools: { listChanged: true },
|
||||||
|
logging: {},
|
||||||
},
|
},
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
description: "Test server",
|
description: "Test server",
|
||||||
|
|
@ -762,6 +763,57 @@ module MCP
|
||||||
assert_instrumentation_data({ method: "resources/templates/list" })
|
assert_instrumentation_data({ method: "resources/templates/list" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#configure_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 "#configure_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
|
test "#handle method with missing required top-level capability returns an error" do
|
||||||
@server.capabilities = {}
|
@server.capabilities = {}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue