Skip to content

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand All @@ -139,7 +141,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 @@ -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"]}")

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 @@ -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"]})")
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_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
27 changes: 25 additions & 2 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "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, :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",
Expand Down Expand Up @@ -63,6 +64,7 @@ def initialize(
end

@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::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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -211,6 +226,7 @@ def default_capabilities
tools: { listChanged: true },
prompts: { listChanged: true },
resources: { listChanged: true },
logging: {},
}
end

Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions test/mcp/logging_message_notification_test.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,20 @@ 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

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
Expand Down Expand Up @@ -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

Expand Down
24 changes: 21 additions & 3 deletions test/mcp/server_notification_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class ServerTest < ActiveSupport::TestCase
prompts: { listChanged: true },
resources: { listChanged: true },
tools: { listChanged: true },
logging: {},
},
serverInfo: {
name: @server_name,
Expand Down