## Motivation and Context This repository does not track Gemfile.lock, so `bundle install` resolves to the latest RuboCop gems. Upgrading rubocop-shopify from 2.x to 3.0.0 surfaces 8 new offenses because the shared config no longer disables `Style/MutableConstant`, newly disables `Lint/RescueException` and `Naming/AccessorMethodName`, and drops `AllowedPatterns` for `Naming/MethodName`. Upgrading rubocop itself to 1.88 surfaces one more offense from the new `Style/ReduceToHash` cop. This change fixes them as follows: - Freeze mutable constants. Regexp constants are also flagged because `TargetRubyVersion` is 2.7, where Regexp literals are not frozen. - Remove inline disable directives that became redundant. - Restore `AllowedPatterns: ['\Atest_']` for `Naming/MethodName` so `def test_` style test names can reference class names such as `NoMethodError`. - Swap out the frozen `SUPPORTED_STABLE_PROTOCOL_VERSIONS` constant in the protocol version fallback test since Mocha cannot stub methods on frozen objects. - Use `to_h` instead of `each_with_object` in `Server#index_resources_by_uri`. Ref: https://rubygems.org/gems/rubocop-shopify/versions/3.0.0 ## How Has This Been Tested? `bundle exec rake` passes: 1402 runs, 0 failures, 0 errors, and RuboCop reports no offenses. The conformance suite also passes against its baseline. ## Breaking Changes None. Freezing these constants only affects code that mutates them in place, which has never been supported.
265 lines
8.7 KiB
Ruby
265 lines
8.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
|
|
module MCP
|
|
class Server
|
|
module Transports
|
|
class StdioNotificationIntegrationTest < ActiveSupport::TestCase
|
|
class MockIO
|
|
attr_reader :output
|
|
|
|
def initialize
|
|
@output = []
|
|
@closed = false
|
|
end
|
|
|
|
def puts(message)
|
|
@output << message
|
|
end
|
|
|
|
def write(message)
|
|
@output << message
|
|
message.length
|
|
end
|
|
|
|
def gets
|
|
nil # Simulate end of input
|
|
end
|
|
|
|
def set_encoding(encoding)
|
|
# Mock implementation
|
|
end
|
|
|
|
def flush
|
|
# Mock implementation
|
|
end
|
|
|
|
def close
|
|
@closed = true
|
|
end
|
|
|
|
def closed?
|
|
@closed
|
|
end
|
|
end
|
|
|
|
setup do
|
|
@original_stdout = $stdout
|
|
@original_stdin = $stdin
|
|
|
|
@mock_stdout = MockIO.new
|
|
@mock_stdin = MockIO.new
|
|
|
|
$stdout = @mock_stdout
|
|
$stdin = @mock_stdin
|
|
|
|
@server = Server.new(
|
|
name: "test_server",
|
|
tools: [],
|
|
prompts: [],
|
|
resources: [],
|
|
)
|
|
@transport = StdioTransport.new(@server)
|
|
end
|
|
|
|
teardown do
|
|
$stdout = @original_stdout
|
|
$stdin = @original_stdin
|
|
end
|
|
|
|
test "server notification methods send JSON-RPC notifications through StdioTransport" do
|
|
# Test tools notification
|
|
@server.notify_tools_list_changed
|
|
|
|
# Test prompts notification
|
|
@server.notify_prompts_list_changed
|
|
|
|
# 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 4, @mock_stdout.output.size
|
|
|
|
# Parse and verify each notification
|
|
notifications = @mock_stdout.output.map { |msg| JSON.parse(msg) }
|
|
|
|
assert_equal "2.0", notifications[0]["jsonrpc"]
|
|
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notifications[0]["method"]
|
|
assert_nil notifications[0]["params"]
|
|
|
|
assert_equal "2.0", notifications[1]["jsonrpc"]
|
|
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notifications[1]["method"]
|
|
assert_nil notifications[1]["params"]
|
|
|
|
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
|
|
# Test the transport's send_notification directly with params
|
|
result = @transport.send_notification("test/notification", { data: "test_value" })
|
|
|
|
assert result
|
|
assert_equal 1, @mock_stdout.output.size
|
|
|
|
notification = JSON.parse(@mock_stdout.output.first)
|
|
assert_equal "2.0", notification["jsonrpc"]
|
|
assert_equal "test/notification", notification["method"]
|
|
assert_equal({ "data" => "test_value" }, notification["params"])
|
|
end
|
|
|
|
test "server continues to work when stdout is closed" do
|
|
# Close stdout
|
|
@mock_stdout.close
|
|
|
|
# Server notifications should not raise errors
|
|
assert_nothing_raised do
|
|
@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
|
|
|
|
test "notifications work with dynamic tool additions" do
|
|
# Define a new tool
|
|
@server.define_tool(
|
|
name: "dynamic_tool",
|
|
description: "A dynamically added tool",
|
|
) do |**_args|
|
|
{ result: "success" }
|
|
end
|
|
|
|
# Clear previous output
|
|
@mock_stdout.output.clear
|
|
|
|
# Manually trigger notification
|
|
@server.notify_tools_list_changed
|
|
|
|
# Check the notification was sent
|
|
assert_equal 1, @mock_stdout.output.size
|
|
|
|
notification = JSON.parse(@mock_stdout.output.first)
|
|
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification["method"]
|
|
|
|
# Verify the tool was added to the server
|
|
assert @server.tools.key?("dynamic_tool")
|
|
end
|
|
|
|
test "notifications are properly formatted JSON-RPC 2.0 messages" do
|
|
# Send a notification
|
|
@server.notify_prompts_list_changed
|
|
|
|
# Verify format
|
|
assert_equal 1, @mock_stdout.output.size
|
|
output = @mock_stdout.output.first
|
|
|
|
# Should be valid JSON
|
|
notification = JSON.parse(output)
|
|
|
|
# Should have required JSON-RPC 2.0 fields
|
|
assert_equal "2.0", notification["jsonrpc"]
|
|
assert notification.key?("method")
|
|
refute notification.key?("id") # Notifications should not have an id
|
|
|
|
# Method should be the expected notification type
|
|
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification["method"]
|
|
end
|
|
|
|
test "multiple notifications are sent as separate JSON messages" do
|
|
# Send multiple notifications rapidly
|
|
5.times do
|
|
@server.notify_tools_list_changed
|
|
end
|
|
|
|
# Each should be a separate JSON message
|
|
assert_equal 5, @mock_stdout.output.size
|
|
|
|
# All should be parseable as JSON
|
|
@mock_stdout.output.each do |msg|
|
|
notification = JSON.parse(msg)
|
|
assert_equal "2.0", notification["jsonrpc"]
|
|
assert_equal Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED, notification["method"]
|
|
end
|
|
end
|
|
|
|
test "transport handles errors gracefully" do
|
|
# Create a stdout that raises errors
|
|
error_stdout = Class.new(MockIO) do
|
|
def puts(message)
|
|
raise IOError, "Simulated IO error"
|
|
end
|
|
end.new
|
|
|
|
$stdout = error_stdout
|
|
|
|
# Notification should return false but not raise
|
|
result = @transport.send_notification("test/notification")
|
|
refute result
|
|
end
|
|
|
|
test "server notification flow works end-to-end with StdioTransport" do
|
|
# This test verifies the complete integration from server to transport
|
|
|
|
# Start with no output
|
|
assert_equal([], @mock_stdout.output)
|
|
|
|
# Add a prompt and notify
|
|
@server.define_prompt(
|
|
name: "test_prompt",
|
|
description: "Test prompt",
|
|
) do
|
|
MCP::PromptResponse.new(messages: [{ role: "user", content: "Test" }])
|
|
end
|
|
|
|
# Manually trigger notification
|
|
@server.notify_prompts_list_changed
|
|
|
|
# Verify notification was sent
|
|
assert_equal 1, @mock_stdout.output.size
|
|
notification = JSON.parse(@mock_stdout.output.first)
|
|
assert_equal Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED, notification["method"]
|
|
|
|
# Add a resource and notify
|
|
@server.resources = [
|
|
MCP::Resource.new(
|
|
uri: "https://test_resource.invalid",
|
|
name: "test-resource",
|
|
title: "Test Resource",
|
|
description: "A test resource",
|
|
mime_type: "text/plain",
|
|
),
|
|
]
|
|
|
|
# Manually trigger notification
|
|
@server.notify_resources_list_changed
|
|
|
|
# Verify both notifications were sent
|
|
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
|
|
end
|
|
end
|