Skip to content
Merged
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
97 changes: 77 additions & 20 deletions lib/mcp_client/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class Client
# @param logger [Logger, nil] optional logger, defaults to STDOUT
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
# @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
# @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-06-18)
# @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25)
def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
@logger = logger || Logger.new($stdout, level: Logger::WARN)
@logger.progname = self.class.name
Expand All @@ -41,7 +41,7 @@ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, ro
@notification_listeners = []
# Elicitation handler (MCP 2025-06-18)
@elicitation_handler = elicitation_handler
# Sampling handler (MCP 2025-06-18)
# Sampling handler (MCP 2025-11-25)
@sampling_handler = sampling_handler
# Roots (MCP 2025-06-18)
@roots = normalize_roots(roots)
Expand All @@ -59,7 +59,7 @@ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, ro
end
# Register roots list handler on each server (MCP 2025-06-18)
server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
# Register sampling handler on each server (MCP 2025-06-18)
# Register sampling handler on each server (MCP 2025-11-25)
server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
end
end
Expand Down Expand Up @@ -857,9 +857,9 @@ def notify_roots_changed
end
end

# Handle sampling/createMessage request from server (MCP 2025-06-18)
# Handle sampling/createMessage request from server (MCP 2025-11-25)
# @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
# @param params [Hash] the sampling parameters (messages, modelPreferences, systemPrompt, maxTokens)
# @param params [Hash] the sampling parameters
# @return [Hash] the sampling response (role, content, model, stopReason)
def handle_sampling_request(_request_id, params)
# If no handler is configured, return an error
Expand All @@ -869,24 +869,18 @@ def handle_sampling_request(_request_id, params)
end

messages = params['messages'] || []
model_preferences = params['modelPreferences']
model_preferences = normalize_model_preferences(params['modelPreferences'])
system_prompt = params['systemPrompt']
max_tokens = params['maxTokens']
include_context = params['includeContext']
temperature = params['temperature']
stop_sequences = params['stopSequences']
metadata = params['metadata']

begin
# Call the user-defined handler
result = case @sampling_handler.arity
when 0
@sampling_handler.call
when 1
@sampling_handler.call(messages)
when 2
@sampling_handler.call(messages, model_preferences)
when 3
@sampling_handler.call(messages, model_preferences, system_prompt)
else
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
end
# Call the user-defined handler with parameters based on arity
result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
include_context, temperature, stop_sequences, metadata)

# Validate and format response
validate_sampling_response(result)
Expand All @@ -897,7 +891,70 @@ def handle_sampling_request(_request_id, params)
end
end

# Validate sampling response from handler (MCP 2025-06-18)
# Call sampling handler with appropriate arity
# @param messages [Array] the messages
# @param model_preferences [Hash, nil] normalized model preferences
# @param system_prompt [String, nil] system prompt
# @param max_tokens [Integer, nil] max tokens
# @param include_context [String, nil] context inclusion setting
# @param temperature [Float, nil] temperature
# @param stop_sequences [Array, nil] stop sequences
# @param metadata [Hash, nil] metadata
# @return [Hash] the handler result
def call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
include_context, temperature, stop_sequences, metadata)
arity = @sampling_handler.arity
# Normalize negative arity (optional params) to minimum required args
arity = -(arity + 1) if arity.negative?
case arity
when 0
@sampling_handler.call
when 1
@sampling_handler.call(messages)
when 2
@sampling_handler.call(messages, model_preferences)
when 3
@sampling_handler.call(messages, model_preferences, system_prompt)
when 4
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
else
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens,
{ 'includeContext' => include_context, 'temperature' => temperature,
Comment on lines +921 to +922

Choose a reason for hiding this comment

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

P1 Badge Preserve optional-arity handlers when dispatching sampling args

The new arity dispatch treats every non-0..4 handler as "5+" and always passes an extra hash, but Ruby lambdas with optional arguments report negative arity (for example ->(messages, prefs, prompt, max_tokens = nil) { ... } has arity -4). Those handlers now fall into this else branch and receive 5 arguments, raising ArgumentError (expected 3..4) and causing sampling requests to fail at runtime; before this change they received 4 arguments and worked.

Useful? React with 👍 / 👎.

'stopSequences' => stop_sequences, 'metadata' => metadata })
end
end

# Normalize and validate modelPreferences from sampling request (MCP 2025-11-25)
# Ensures hints is an array of hashes with 'name', and priority values are clamped to 0.0..1.0
# @param prefs [Hash, nil] raw modelPreferences from request
# @return [Hash, nil] normalized modelPreferences or nil
def normalize_model_preferences(prefs)
return nil if prefs.nil?
return nil unless prefs.is_a?(Hash)

normalized = {}

# Normalize hints: array of { 'name' => String }
if prefs['hints']
normalized['hints'] = Array(prefs['hints']).filter_map do |hint|
next nil unless hint.is_a?(Hash) && hint['name']

{ 'name' => hint['name'].to_s }
end
end

# Normalize priority values (0.0 to 1.0)
%w[costPriority speedPriority intelligencePriority].each do |key|
next unless prefs.key?(key)

value = prefs[key]
normalized[key] = value.is_a?(Numeric) ? value.to_f.clamp(0.0, 1.0) : nil
end

normalized
end

# Validate sampling response from handler (MCP 2025-11-25)
# @param result [Hash] the result from the sampling handler
# @return [Hash] validated sampling response
def validate_sampling_response(result)
Expand Down
6 changes: 3 additions & 3 deletions lib/mcp_client/json_rpc_common.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ def build_jsonrpc_notification(method, params)
# @return [Hash] the initialization parameters
def initialization_params
capabilities = {
'elicitation' => {}, # MCP 2025-11-25: form and URL mode elicitation
'roots' => { 'listChanged' => true }, # MCP 2025-06-18: Support for roots
'sampling' => {} # MCP 2025-06-18: Support for server-initiated LLM sampling
'elicitation' => {}, # MCP 2025-11-25: Support for server-initiated user interactions
'roots' => { 'listChanged' => true }, # MCP 2025-11-25: Support for roots
'sampling' => {} # MCP 2025-11-25: Support for server-initiated LLM sampling
}

{
Expand Down
8 changes: 4 additions & 4 deletions lib/mcp_client/server_sse.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def initialize(base_url:, headers: {}, read_timeout: 30, ping: 10,
@activity_timer_thread = nil
@elicitation_request_callback = nil # MCP 2025-06-18
@roots_list_request_callback = nil # MCP 2025-06-18
@sampling_request_callback = nil # MCP 2025-06-18
@sampling_request_callback = nil # MCP 2025-11-25
end

# Stream tool call fallback for SSE transport (yields single result)
Expand Down Expand Up @@ -455,7 +455,7 @@ def on_roots_list_request(&block)
@roots_list_request_callback = block
end

# Register a callback for sampling requests (MCP 2025-06-18)
# Register a callback for sampling requests (MCP 2025-11-25)
# @param block [Proc] callback that receives (request_id, params) and returns response hash
# @return [void]
def on_sampling_request(&block)
Expand Down Expand Up @@ -545,7 +545,7 @@ def send_roots_list_response(request_id, result)
@logger.error("Error sending roots/list response: #{e.message}")
end

# Handle sampling/createMessage request from server (MCP 2025-06-18)
# Handle sampling/createMessage request from server (MCP 2025-11-25)
# @param request_id [String, Integer] the JSON-RPC request ID
# @param params [Hash] the sampling parameters
# @return [void]
Expand All @@ -564,7 +564,7 @@ def handle_sampling_create_message(request_id, params)
send_sampling_response(request_id, result)
end

# Send sampling response back to server via HTTP POST (MCP 2025-06-18)
# Send sampling response back to server via HTTP POST (MCP 2025-11-25)
# @param request_id [String, Integer] the JSON-RPC request ID
# @param result [Hash] the sampling result (role, content, model, stopReason)
# @return [void]
Expand Down
8 changes: 4 additions & 4 deletions lib/mcp_client/server_stdio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def initialize(command:, retries: 0, retry_backoff: 1, read_timeout: READ_TIMEOU
@env = env || {}
@elicitation_request_callback = nil # MCP 2025-06-18
@roots_list_request_callback = nil # MCP 2025-06-18
@sampling_request_callback = nil # MCP 2025-06-18
@sampling_request_callback = nil # MCP 2025-11-25
end

# Server info from the initialize response
Expand Down Expand Up @@ -406,7 +406,7 @@ def on_roots_list_request(&block)
@roots_list_request_callback = block
end

# Register a callback for sampling requests (MCP 2025-06-18)
# Register a callback for sampling requests (MCP 2025-11-25)
# @param block [Proc] callback that receives (request_id, params) and returns response hash
# @return [void]
def on_sampling_request(&block)
Expand Down Expand Up @@ -477,7 +477,7 @@ def handle_roots_list(request_id, params)
send_roots_list_response(request_id, result)
end

# Handle sampling/createMessage request from server (MCP 2025-06-18)
# Handle sampling/createMessage request from server (MCP 2025-11-25)
# @param request_id [String, Integer] the JSON-RPC request ID
# @param params [Hash] the sampling parameters
# @return [void]
Expand Down Expand Up @@ -509,7 +509,7 @@ def send_roots_list_response(request_id, result)
send_message(response)
end

# Send sampling response back to server (MCP 2025-06-18)
# Send sampling response back to server (MCP 2025-11-25)
# @param request_id [String, Integer] the JSON-RPC request ID
# @param result [Hash] the sampling result (role, content, model, stopReason)
# @return [void]
Expand Down
8 changes: 4 additions & 4 deletions lib/mcp_client/server_streamable_http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def initialize(base_url:, **options)
@buffer = '' # Buffer for partial SSE event data
@elicitation_request_callback = nil # MCP 2025-06-18
@roots_list_request_callback = nil # MCP 2025-06-18
@sampling_request_callback = nil # MCP 2025-06-18
@sampling_request_callback = nil # MCP 2025-11-25
end

# Connect to the MCP server over Streamable HTTP
Expand Down Expand Up @@ -496,7 +496,7 @@ def on_roots_list_request(&block)
@roots_list_request_callback = block
end

# Register a callback for sampling requests (MCP 2025-06-18)
# Register a callback for sampling requests (MCP 2025-11-25)
# @param block [Proc] callback that receives (request_id, params) and returns response hash
# @return [void]
def on_sampling_request(&block)
Expand Down Expand Up @@ -920,7 +920,7 @@ def handle_roots_list(request_id, params)
send_roots_list_response(request_id, result)
end

# Handle sampling/createMessage request from server (MCP 2025-06-18)
# Handle sampling/createMessage request from server (MCP 2025-11-25)
# @param request_id [String, Integer] the JSON-RPC request ID
# @param params [Hash] the sampling parameters
# @return [void]
Expand Down Expand Up @@ -956,7 +956,7 @@ def send_roots_list_response(request_id, result)
@logger.error("Error sending roots/list response: #{e.message}")
end

# Send sampling response back to server via HTTP POST (MCP 2025-06-18)
# Send sampling response back to server via HTTP POST (MCP 2025-11-25)
# @param request_id [String, Integer] the JSON-RPC request ID
# @param result [Hash] the sampling result (role, content, model, stopReason)
# @return [void]
Expand Down
Loading