-
Notifications
You must be signed in to change notification settings - Fork 93
Add logging support
#103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add logging support
#103
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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,84 @@ 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). | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI: #189 Can you update this URL to the following?
Suggested change
|
||||||
|
|
||||||
| **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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nits.
Suggested change
|
||||||
| ) | ||||||
| ``` | ||||||
|
|
||||||
| **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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ditto.
Suggested change
|
||||||
| - 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 | ||||||
|
|
||||||
|
|
@@ -153,7 +232,6 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, statele | |||||
|
|
||||||
| ### Unsupported Features (to be implemented in future versions) | ||||||
|
|
||||||
| - Log Level | ||||||
| - Resource subscriptions | ||||||
| - Completions | ||||||
| - Elicitation | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,6 +105,8 @@ def process_request(request, id_validation_pattern:, &method_finder) | |
| result = method.call(params) | ||
|
|
||
| success_response(id: id, result: result) | ||
| rescue MCP::Server::RequestHandlerError => e | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm slightly concerned about the circular reference between |
||
| handle_request_error(e, id, id_validation_pattern) | ||
| rescue StandardError => e | ||
| error_response(id: id, id_validation_pattern: 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, id_validation_pattern: id_validation_pattern, error: { | ||
| code: code, | ||
| message: message, | ||
| data: error.message, | ||
| }) | ||
| end | ||
|
|
||
| def valid_version?(version) | ||
| version == Version::V2_0 | ||
| end | ||
|
|
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intuitively I think
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's more intuitive. Fixed. |
||
|
|
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| require_relative "../json_rpc_handler" | ||
| require_relative "instrumentation" | ||
| require_relative "methods" | ||
| require_relative "logging_message_notification" | ||
|
|
||
| module MCP | ||
| class ToolNotUnique < StandardError | ||
|
|
@@ -40,7 +41,7 @@ def initialize(method_name) | |
|
|
||
| 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( | ||
| description: nil, | ||
|
|
@@ -78,6 +79,7 @@ def initialize( | |
| validate! | ||
|
|
||
| @capabilities = capabilities || default_capabilities | ||
| @logging_message_notification = nil | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
|
||
| @handlers = { | ||
| Methods::RESOURCES_LIST => method(:list_resources), | ||
|
|
@@ -90,12 +92,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 => ->(_) {}, | ||
| Methods::ELICITATION_CREATE => ->(_) {}, | ||
| } | ||
| @transport = transport | ||
|
|
@@ -162,6 +164,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" => 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) | ||
| @handlers[Methods::RESOURCES_LIST] = block | ||
| end | ||
|
|
@@ -284,6 +298,7 @@ def default_capabilities | |
| tools: { listChanged: true }, | ||
| prompts: { listChanged: true }, | ||
| resources: { listChanged: true }, | ||
| logging: {}, | ||
| } | ||
| end | ||
|
|
||
|
|
@@ -307,6 +322,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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
Severityvalues (i.e., from0to7) appear to be an internal implementation detail and not something users need to be aware of. How about renaming theLevelheading toSeverity Leveland removing theSeveritycolumn?