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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -141,7 +219,6 @@ server.notify_tools_list_changed

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

- Log Level
- Resource subscriptions
- Completions

Expand Down
4 changes: 4 additions & 0 deletions examples/streamable_http_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}")

Expand Down
1 change: 1 addition & 0 deletions examples/streamable_http_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]})")
Expand Down
20 changes: 20 additions & 0 deletions lib/json_rpc_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor Author

@dak2 dak2 Nov 2, 2025

Choose a reason for hiding this comment

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

I'm slightly concerned about the circular reference between lib/json_rpc_handler.rb and lib/mcp/server.rb, but I consider it to be fundamentally tied to the mechanism that handles server requests. Therefore, I think it's acceptable to be aware of RequestHandlerError.

handle_request_error(e, id, id_validation_pattern)
rescue StandardError => e
error_response(id:, id_validation_pattern:, error: {
code: ErrorCode::INTERNAL_ERROR,
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions lib/mcp/logging_message_notification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

require "json_rpc_handler"

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

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Member

Choose a reason for hiding this comment

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

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's more intuitive. Fixed.


private attr_reader(:level)

def initialize(level:)
@level = level
end

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

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

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

include Instrumentation

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

def initialize(
name: "model_context_protocol",
Expand Down Expand Up @@ -62,6 +63,7 @@ def initialize(
validate!

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

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

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

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

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

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

Copy link
Member

Choose a reason for hiding this comment

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

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


@handlers = {
Methods::RESOURCES_LIST => method(:list_resources),
Expand All @@ -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
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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


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

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

Expand All @@ -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
Expand Down
90 changes: 90 additions & 0 deletions test/json_rpc_handler_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading