ruby-mcp-client 0.9.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +16 -6
- data/lib/mcp_client/audio_content.rb +46 -0
- data/lib/mcp_client/client.rb +265 -54
- data/lib/mcp_client/elicitation_validator.rb +262 -0
- data/lib/mcp_client/errors.rb +6 -0
- data/lib/mcp_client/http_transport_base.rb +2 -0
- data/lib/mcp_client/json_rpc_common.rb +3 -3
- data/lib/mcp_client/resource.rb +8 -0
- data/lib/mcp_client/resource_link.rb +63 -0
- data/lib/mcp_client/server_http.rb +17 -7
- data/lib/mcp_client/server_sse.rb +9 -6
- data/lib/mcp_client/server_stdio.rb +9 -6
- data/lib/mcp_client/server_streamable_http.rb +23 -10
- data/lib/mcp_client/task.rb +127 -0
- data/lib/mcp_client/tool.rb +73 -9
- data/lib/mcp_client/version.rb +2 -2
- data/lib/mcp_client.rb +27 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7e861ecba26ec407886fbb6d169b85c78ecba0768552b512cbe6c9c1f6216ad
|
|
4
|
+
data.tar.gz: d71bab971376849e2bf72e2598116087cdb4f072c556368471d1d9adfe1bd7b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20d61bad3b57193dbc1a8019b87ec85758c55f61edc7c37808e7f1bc3a4fb60f35532f694aa68334273da31245ef6541cddbf2c4d0dc720d122bfae09488b415
|
|
7
|
+
data.tar.gz: e3e3ec6840cf41e0b18d6112a7ff602a798cdcb6108b1c75afd5ecaf68724adee2109dca6782e884e7b2db51d8dd8ef09fd414e20d3d7ec0c1bc072225e33427
|
data/README.md
CHANGED
|
@@ -28,16 +28,18 @@ Built-in API conversions: `to_openai_tools()`, `to_anthropic_tools()`, `to_googl
|
|
|
28
28
|
|
|
29
29
|
## MCP Protocol Support
|
|
30
30
|
|
|
31
|
-
Implements **MCP 2025-
|
|
31
|
+
Implements **MCP 2025-11-25** specification:
|
|
32
32
|
|
|
33
|
-
- **Tools**: list, call, streaming, annotations, structured outputs
|
|
33
|
+
- **Tools**: list, call, streaming, annotations (hint-style), structured outputs, title
|
|
34
34
|
- **Prompts**: list, get with parameters
|
|
35
|
-
- **Resources**: list, read, templates, subscriptions, pagination
|
|
35
|
+
- **Resources**: list, read, templates, subscriptions, pagination, ResourceLink content
|
|
36
36
|
- **Elicitation**: Server-initiated user interactions (stdio, SSE, Streamable HTTP)
|
|
37
37
|
- **Roots**: Filesystem scope boundaries with change notifications
|
|
38
|
-
- **Sampling**: Server-requested LLM completions
|
|
39
|
-
- **Completion**: Autocomplete for prompts/resources
|
|
38
|
+
- **Sampling**: Server-requested LLM completions with modelPreferences
|
|
39
|
+
- **Completion**: Autocomplete for prompts/resources with context
|
|
40
40
|
- **Logging**: Server log messages with level filtering
|
|
41
|
+
- **Tasks**: Structured task management with progress tracking
|
|
42
|
+
- **Audio**: Audio content type support
|
|
41
43
|
- **OAuth 2.1**: PKCE, server discovery, dynamic registration
|
|
42
44
|
|
|
43
45
|
## Quick Connect API (Recommended)
|
|
@@ -114,12 +116,20 @@ contents.each do |content|
|
|
|
114
116
|
end
|
|
115
117
|
```
|
|
116
118
|
|
|
117
|
-
## MCP 2025-
|
|
119
|
+
## MCP 2025-11-25 Features
|
|
118
120
|
|
|
119
121
|
### Tool Annotations
|
|
120
122
|
|
|
121
123
|
```ruby
|
|
122
124
|
tool = client.find_tool('delete_user')
|
|
125
|
+
|
|
126
|
+
# Hint-style annotations (MCP 2025-11-25)
|
|
127
|
+
tool.read_only_hint? # Defaults to true; tool does not modify environment
|
|
128
|
+
tool.destructive_hint? # Defaults to false; tool may perform destructive updates
|
|
129
|
+
tool.idempotent_hint? # Defaults to false; repeated calls have no additional effect
|
|
130
|
+
tool.open_world_hint? # Defaults to true; tool may interact with external entities
|
|
131
|
+
|
|
132
|
+
# Legacy annotations
|
|
123
133
|
tool.read_only? # Safe to execute?
|
|
124
134
|
tool.destructive? # Warning: destructive operation
|
|
125
135
|
tool.requires_confirmation? # Needs user confirmation
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# Representation of MCP audio content (MCP 2025-11-25)
|
|
5
|
+
# Used for base64-encoded audio data in messages and tool results
|
|
6
|
+
class AudioContent
|
|
7
|
+
# @!attribute [r] data
|
|
8
|
+
# @return [String] base64-encoded audio data
|
|
9
|
+
# @!attribute [r] mime_type
|
|
10
|
+
# @return [String] MIME type of the audio (e.g., 'audio/wav', 'audio/mpeg', 'audio/ogg')
|
|
11
|
+
# @!attribute [r] annotations
|
|
12
|
+
# @return [Hash, nil] optional annotations that provide hints to clients
|
|
13
|
+
attr_reader :data, :mime_type, :annotations
|
|
14
|
+
|
|
15
|
+
# Initialize audio content
|
|
16
|
+
# @param data [String] base64-encoded audio data
|
|
17
|
+
# @param mime_type [String] MIME type of the audio
|
|
18
|
+
# @param annotations [Hash, nil] optional annotations that provide hints to clients
|
|
19
|
+
def initialize(data:, mime_type:, annotations: nil)
|
|
20
|
+
raise ArgumentError, 'AudioContent requires data' if data.nil? || data.empty?
|
|
21
|
+
raise ArgumentError, 'AudioContent requires mime_type' if mime_type.nil? || mime_type.empty?
|
|
22
|
+
|
|
23
|
+
@data = data
|
|
24
|
+
@mime_type = mime_type
|
|
25
|
+
@annotations = annotations
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Create an AudioContent instance from JSON data
|
|
29
|
+
# @param data [Hash] JSON data from MCP server
|
|
30
|
+
# @return [MCPClient::AudioContent] audio content instance
|
|
31
|
+
def self.from_json(json_data)
|
|
32
|
+
new(
|
|
33
|
+
data: json_data['data'],
|
|
34
|
+
mime_type: json_data['mimeType'],
|
|
35
|
+
annotations: json_data['annotations']
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get the decoded audio content
|
|
40
|
+
# @return [String] decoded binary audio data
|
|
41
|
+
def content
|
|
42
|
+
require 'base64'
|
|
43
|
+
Base64.decode64(@data)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/mcp_client/client.rb
CHANGED
|
@@ -25,7 +25,7 @@ module MCPClient
|
|
|
25
25
|
# @param logger [Logger, nil] optional logger, defaults to STDOUT
|
|
26
26
|
# @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
|
|
27
27
|
# @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
|
|
28
|
-
# @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-
|
|
28
|
+
# @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-11-25)
|
|
29
29
|
def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
|
|
30
30
|
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
31
31
|
@logger.progname = self.class.name
|
|
@@ -41,7 +41,7 @@ module MCPClient
|
|
|
41
41
|
@notification_listeners = []
|
|
42
42
|
# Elicitation handler (MCP 2025-06-18)
|
|
43
43
|
@elicitation_handler = elicitation_handler
|
|
44
|
-
# Sampling handler (MCP 2025-
|
|
44
|
+
# Sampling handler (MCP 2025-11-25)
|
|
45
45
|
@sampling_handler = sampling_handler
|
|
46
46
|
# Roots (MCP 2025-06-18)
|
|
47
47
|
@roots = normalize_roots(roots)
|
|
@@ -59,7 +59,7 @@ module MCPClient
|
|
|
59
59
|
end
|
|
60
60
|
# Register roots list handler on each server (MCP 2025-06-18)
|
|
61
61
|
server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
|
|
62
|
-
# Register sampling handler on each server (MCP 2025-
|
|
62
|
+
# Register sampling handler on each server (MCP 2025-11-25)
|
|
63
63
|
server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
|
|
64
64
|
end
|
|
65
65
|
end
|
|
@@ -486,13 +486,85 @@ module MCPClient
|
|
|
486
486
|
# Request completion suggestions from a server (MCP 2025-06-18)
|
|
487
487
|
# @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
|
|
488
488
|
# @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
|
|
489
|
+
# @param context [Hash, nil] optional context for the completion (MCP 2025-11-25),
|
|
490
|
+
# e.g., { 'arguments' => { 'arg1' => 'value1' } } for previously-resolved arguments
|
|
489
491
|
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
490
492
|
# @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
|
|
491
493
|
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
492
494
|
# @raise [MCPClient::Errors::ServerError] if server returns an error
|
|
493
|
-
def complete(ref:, argument:, server: nil)
|
|
495
|
+
def complete(ref:, argument:, context: nil, server: nil)
|
|
494
496
|
srv = select_server(server)
|
|
495
|
-
srv.complete(ref: ref, argument: argument)
|
|
497
|
+
srv.complete(ref: ref, argument: argument, context: context)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Create a new task on a server (MCP 2025-11-25)
|
|
501
|
+
# Tasks represent long-running operations that can report progress
|
|
502
|
+
# @param method [String] the method to execute as a task
|
|
503
|
+
# @param params [Hash] parameters for the task method
|
|
504
|
+
# @param progress_token [String, nil] optional token for receiving progress notifications
|
|
505
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
506
|
+
# @return [MCPClient::Task] the created task
|
|
507
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
508
|
+
# @raise [MCPClient::Errors::TaskError] if task creation fails
|
|
509
|
+
def create_task(method, params: {}, progress_token: nil, server: nil)
|
|
510
|
+
srv = select_server(server)
|
|
511
|
+
rpc_params = { method: method, params: params }
|
|
512
|
+
rpc_params[:progressToken] = progress_token if progress_token
|
|
513
|
+
|
|
514
|
+
begin
|
|
515
|
+
result = srv.rpc_request('tasks/create', rpc_params)
|
|
516
|
+
MCPClient::Task.from_json(result, server: srv)
|
|
517
|
+
rescue MCPClient::Errors::ServerError, MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
518
|
+
raise MCPClient::Errors::TaskError, "Error creating task: #{e.message}"
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Get the current state of a task (MCP 2025-11-25)
|
|
523
|
+
# @param task_id [String] the ID of the task to query
|
|
524
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
525
|
+
# @return [MCPClient::Task] the task with current state
|
|
526
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
527
|
+
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
528
|
+
# @raise [MCPClient::Errors::TaskError] if retrieving the task fails
|
|
529
|
+
def get_task(task_id, server: nil)
|
|
530
|
+
srv = select_server(server)
|
|
531
|
+
|
|
532
|
+
begin
|
|
533
|
+
result = srv.rpc_request('tasks/get', { id: task_id })
|
|
534
|
+
MCPClient::Task.from_json(result, server: srv)
|
|
535
|
+
rescue MCPClient::Errors::ServerError => e
|
|
536
|
+
if e.message.include?('not found') || e.message.include?('unknown task')
|
|
537
|
+
raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
|
|
541
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
542
|
+
raise MCPClient::Errors::TaskError, "Error getting task '#{task_id}': #{e.message}"
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Cancel a running task (MCP 2025-11-25)
|
|
547
|
+
# @param task_id [String] the ID of the task to cancel
|
|
548
|
+
# @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
|
|
549
|
+
# @return [MCPClient::Task] the task with updated (cancelled) state
|
|
550
|
+
# @raise [MCPClient::Errors::ServerNotFound] if no server is available
|
|
551
|
+
# @raise [MCPClient::Errors::TaskNotFound] if the task does not exist
|
|
552
|
+
# @raise [MCPClient::Errors::TaskError] if cancellation fails
|
|
553
|
+
def cancel_task(task_id, server: nil)
|
|
554
|
+
srv = select_server(server)
|
|
555
|
+
|
|
556
|
+
begin
|
|
557
|
+
result = srv.rpc_request('tasks/cancel', { id: task_id })
|
|
558
|
+
MCPClient::Task.from_json(result, server: srv)
|
|
559
|
+
rescue MCPClient::Errors::ServerError => e
|
|
560
|
+
if e.message.include?('not found') || e.message.include?('unknown task')
|
|
561
|
+
raise MCPClient::Errors::TaskNotFound, "Task '#{task_id}' not found"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
|
|
565
|
+
rescue MCPClient::Errors::TransportError, MCPClient::Errors::ConnectionError => e
|
|
566
|
+
raise MCPClient::Errors::TaskError, "Error cancelling task '#{task_id}': #{e.message}"
|
|
567
|
+
end
|
|
496
568
|
end
|
|
497
569
|
|
|
498
570
|
# Set the logging level on all connected servers (MCP 2025-06-18)
|
|
@@ -606,7 +678,17 @@ module MCPClient
|
|
|
606
678
|
required = schema['required'] || schema[:required]
|
|
607
679
|
return unless required.is_a?(Array)
|
|
608
680
|
|
|
681
|
+
properties = schema['properties'] || schema[:properties] || {}
|
|
682
|
+
|
|
609
683
|
missing = required.map(&:to_s) - parameters.keys.map(&:to_s)
|
|
684
|
+
|
|
685
|
+
# Exclude required params that have a default value in the schema,
|
|
686
|
+
# since the server will apply the default.
|
|
687
|
+
missing = missing.reject do |param|
|
|
688
|
+
prop = properties[param] || properties[param.to_sym]
|
|
689
|
+
prop.is_a?(Hash) && (prop.key?('default') || prop.key?(:default))
|
|
690
|
+
end
|
|
691
|
+
|
|
610
692
|
return unless missing.any?
|
|
611
693
|
|
|
612
694
|
raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
|
|
@@ -683,7 +765,8 @@ module MCPClient
|
|
|
683
765
|
end
|
|
684
766
|
end
|
|
685
767
|
|
|
686
|
-
# Handle elicitation request from server (MCP 2025-
|
|
768
|
+
# Handle elicitation request from server (MCP 2025-11-25)
|
|
769
|
+
# Supports both form mode (structured data) and URL mode (out-of-band interaction).
|
|
687
770
|
# @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
|
|
688
771
|
# @param params [Hash] the elicitation parameters
|
|
689
772
|
# @return [Hash] the elicitation response
|
|
@@ -694,43 +777,17 @@ module MCPClient
|
|
|
694
777
|
return { 'action' => 'decline' }
|
|
695
778
|
end
|
|
696
779
|
|
|
780
|
+
mode = params['mode'] || 'form'
|
|
697
781
|
message = params['message']
|
|
698
|
-
schema = params['schema'] || params['requestedSchema']
|
|
699
|
-
metadata = params['metadata']
|
|
700
782
|
|
|
701
783
|
begin
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
when 0
|
|
705
|
-
@elicitation_handler.call
|
|
706
|
-
when 1
|
|
707
|
-
@elicitation_handler.call(message)
|
|
708
|
-
when 2, -1
|
|
709
|
-
@elicitation_handler.call(message, schema)
|
|
784
|
+
result = if mode == 'url'
|
|
785
|
+
handle_url_elicitation(params, message)
|
|
710
786
|
else
|
|
711
|
-
|
|
787
|
+
handle_form_elicitation(params, message)
|
|
712
788
|
end
|
|
713
789
|
|
|
714
|
-
|
|
715
|
-
case result
|
|
716
|
-
when Hash
|
|
717
|
-
if result['action']
|
|
718
|
-
normalised_action_response(result)
|
|
719
|
-
elsif result[:action]
|
|
720
|
-
# Convert symbol keys to strings
|
|
721
|
-
{
|
|
722
|
-
'action' => result[:action].to_s,
|
|
723
|
-
'content' => result[:content]
|
|
724
|
-
}.compact.then { |payload| normalised_action_response(payload) }
|
|
725
|
-
else
|
|
726
|
-
# Assume it's content for an accept action
|
|
727
|
-
{ 'action' => 'accept', 'content' => result }
|
|
728
|
-
end
|
|
729
|
-
when nil
|
|
730
|
-
{ 'action' => 'cancel' }
|
|
731
|
-
else
|
|
732
|
-
{ 'action' => 'accept', 'content' => result }
|
|
733
|
-
end
|
|
790
|
+
format_elicitation_response(result, params)
|
|
734
791
|
rescue StandardError => e
|
|
735
792
|
@logger.error("Elicitation handler error: #{e.message}")
|
|
736
793
|
@logger.debug(e.backtrace.join("\n"))
|
|
@@ -738,6 +795,103 @@ module MCPClient
|
|
|
738
795
|
end
|
|
739
796
|
end
|
|
740
797
|
|
|
798
|
+
# Handle form mode elicitation (MCP 2025-11-25)
|
|
799
|
+
# @param params [Hash] the elicitation parameters
|
|
800
|
+
# @param message [String] the human-readable message
|
|
801
|
+
# @return [Object] handler result
|
|
802
|
+
def handle_form_elicitation(params, message)
|
|
803
|
+
schema = params['requestedSchema'] || params['schema']
|
|
804
|
+
metadata = params['metadata']
|
|
805
|
+
|
|
806
|
+
# Validate schema if present
|
|
807
|
+
if schema
|
|
808
|
+
schema_errors = ElicitationValidator.validate_schema(schema)
|
|
809
|
+
@logger.warn("Elicitation schema validation warnings: #{schema_errors.join('; ')}") unless schema_errors.empty?
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Call the user-defined handler
|
|
813
|
+
case @elicitation_handler.arity
|
|
814
|
+
when 0
|
|
815
|
+
@elicitation_handler.call
|
|
816
|
+
when 1
|
|
817
|
+
@elicitation_handler.call(message)
|
|
818
|
+
when 2, -1
|
|
819
|
+
@elicitation_handler.call(message, schema)
|
|
820
|
+
else
|
|
821
|
+
@elicitation_handler.call(message, schema, metadata)
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Handle URL mode elicitation (MCP 2025-11-25)
|
|
826
|
+
# @param params [Hash] the elicitation parameters
|
|
827
|
+
# @param message [String] the human-readable message
|
|
828
|
+
# @return [Object] handler result
|
|
829
|
+
def handle_url_elicitation(params, message)
|
|
830
|
+
url = params['url']
|
|
831
|
+
elicitation_id = params['elicitationId']
|
|
832
|
+
|
|
833
|
+
# Call handler with URL-mode specific params
|
|
834
|
+
case @elicitation_handler.arity
|
|
835
|
+
when 0
|
|
836
|
+
@elicitation_handler.call
|
|
837
|
+
when 1
|
|
838
|
+
@elicitation_handler.call(message)
|
|
839
|
+
when 2, -1
|
|
840
|
+
@elicitation_handler.call(message, { 'mode' => 'url', 'url' => url, 'elicitationId' => elicitation_id })
|
|
841
|
+
else
|
|
842
|
+
@elicitation_handler.call(message, { 'mode' => 'url', 'url' => url, 'elicitationId' => elicitation_id },
|
|
843
|
+
params['metadata'])
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
# Format and validate the elicitation response
|
|
848
|
+
# @param result [Object] handler result
|
|
849
|
+
# @param params [Hash] original request params (for schema validation)
|
|
850
|
+
# @return [Hash] formatted response
|
|
851
|
+
def format_elicitation_response(result, params)
|
|
852
|
+
response = case result
|
|
853
|
+
when Hash
|
|
854
|
+
if result['action']
|
|
855
|
+
normalised_action_response(result)
|
|
856
|
+
elsif result[:action]
|
|
857
|
+
{
|
|
858
|
+
'action' => result[:action].to_s,
|
|
859
|
+
'content' => result[:content]
|
|
860
|
+
}.compact.then { |payload| normalised_action_response(payload) }
|
|
861
|
+
else
|
|
862
|
+
{ 'action' => 'accept', 'content' => result }
|
|
863
|
+
end
|
|
864
|
+
when nil
|
|
865
|
+
{ 'action' => 'cancel' }
|
|
866
|
+
else
|
|
867
|
+
{ 'action' => 'accept', 'content' => result }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# Validate content against schema for form mode accept responses
|
|
871
|
+
validate_elicitation_content(response, params)
|
|
872
|
+
|
|
873
|
+
response
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Validate elicitation response content against the requestedSchema
|
|
877
|
+
# @param response [Hash] the formatted response
|
|
878
|
+
# @param params [Hash] original request params
|
|
879
|
+
# @return [void]
|
|
880
|
+
def validate_elicitation_content(response, params)
|
|
881
|
+
return unless response['action'] == 'accept' && response['content'].is_a?(Hash)
|
|
882
|
+
|
|
883
|
+
mode = params['mode'] || 'form'
|
|
884
|
+
return unless mode == 'form'
|
|
885
|
+
|
|
886
|
+
schema = params['requestedSchema'] || params['schema']
|
|
887
|
+
return unless schema.is_a?(Hash)
|
|
888
|
+
|
|
889
|
+
errors = ElicitationValidator.validate_content(response['content'], schema)
|
|
890
|
+
return if errors.empty?
|
|
891
|
+
|
|
892
|
+
@logger.warn("Elicitation content validation warnings: #{errors.join('; ')}")
|
|
893
|
+
end
|
|
894
|
+
|
|
741
895
|
# Ensure the action value conforms to MCP spec (accept, decline, cancel)
|
|
742
896
|
# Falls back to accept for unknown action values.
|
|
743
897
|
def normalised_action_response(result)
|
|
@@ -785,9 +939,9 @@ module MCPClient
|
|
|
785
939
|
end
|
|
786
940
|
end
|
|
787
941
|
|
|
788
|
-
# Handle sampling/createMessage request from server (MCP 2025-
|
|
942
|
+
# Handle sampling/createMessage request from server (MCP 2025-11-25)
|
|
789
943
|
# @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
|
|
790
|
-
# @param params [Hash] the sampling parameters
|
|
944
|
+
# @param params [Hash] the sampling parameters
|
|
791
945
|
# @return [Hash] the sampling response (role, content, model, stopReason)
|
|
792
946
|
def handle_sampling_request(_request_id, params)
|
|
793
947
|
# If no handler is configured, return an error
|
|
@@ -797,24 +951,18 @@ module MCPClient
|
|
|
797
951
|
end
|
|
798
952
|
|
|
799
953
|
messages = params['messages'] || []
|
|
800
|
-
model_preferences = params['modelPreferences']
|
|
954
|
+
model_preferences = normalize_model_preferences(params['modelPreferences'])
|
|
801
955
|
system_prompt = params['systemPrompt']
|
|
802
956
|
max_tokens = params['maxTokens']
|
|
957
|
+
include_context = params['includeContext']
|
|
958
|
+
temperature = params['temperature']
|
|
959
|
+
stop_sequences = params['stopSequences']
|
|
960
|
+
metadata = params['metadata']
|
|
803
961
|
|
|
804
962
|
begin
|
|
805
|
-
# Call the user-defined handler
|
|
806
|
-
result =
|
|
807
|
-
|
|
808
|
-
@sampling_handler.call
|
|
809
|
-
when 1
|
|
810
|
-
@sampling_handler.call(messages)
|
|
811
|
-
when 2
|
|
812
|
-
@sampling_handler.call(messages, model_preferences)
|
|
813
|
-
when 3
|
|
814
|
-
@sampling_handler.call(messages, model_preferences, system_prompt)
|
|
815
|
-
else
|
|
816
|
-
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
|
|
817
|
-
end
|
|
963
|
+
# Call the user-defined handler with parameters based on arity
|
|
964
|
+
result = call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
|
|
965
|
+
include_context, temperature, stop_sequences, metadata)
|
|
818
966
|
|
|
819
967
|
# Validate and format response
|
|
820
968
|
validate_sampling_response(result)
|
|
@@ -825,7 +973,70 @@ module MCPClient
|
|
|
825
973
|
end
|
|
826
974
|
end
|
|
827
975
|
|
|
828
|
-
#
|
|
976
|
+
# Call sampling handler with appropriate arity
|
|
977
|
+
# @param messages [Array] the messages
|
|
978
|
+
# @param model_preferences [Hash, nil] normalized model preferences
|
|
979
|
+
# @param system_prompt [String, nil] system prompt
|
|
980
|
+
# @param max_tokens [Integer, nil] max tokens
|
|
981
|
+
# @param include_context [String, nil] context inclusion setting
|
|
982
|
+
# @param temperature [Float, nil] temperature
|
|
983
|
+
# @param stop_sequences [Array, nil] stop sequences
|
|
984
|
+
# @param metadata [Hash, nil] metadata
|
|
985
|
+
# @return [Hash] the handler result
|
|
986
|
+
def call_sampling_handler(messages, model_preferences, system_prompt, max_tokens,
|
|
987
|
+
include_context, temperature, stop_sequences, metadata)
|
|
988
|
+
arity = @sampling_handler.arity
|
|
989
|
+
# Normalize negative arity (optional params) to minimum required args
|
|
990
|
+
arity = -(arity + 1) if arity.negative?
|
|
991
|
+
case arity
|
|
992
|
+
when 0
|
|
993
|
+
@sampling_handler.call
|
|
994
|
+
when 1
|
|
995
|
+
@sampling_handler.call(messages)
|
|
996
|
+
when 2
|
|
997
|
+
@sampling_handler.call(messages, model_preferences)
|
|
998
|
+
when 3
|
|
999
|
+
@sampling_handler.call(messages, model_preferences, system_prompt)
|
|
1000
|
+
when 4
|
|
1001
|
+
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
|
|
1002
|
+
else
|
|
1003
|
+
@sampling_handler.call(messages, model_preferences, system_prompt, max_tokens,
|
|
1004
|
+
{ 'includeContext' => include_context, 'temperature' => temperature,
|
|
1005
|
+
'stopSequences' => stop_sequences, 'metadata' => metadata })
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# Normalize and validate modelPreferences from sampling request (MCP 2025-11-25)
|
|
1010
|
+
# Ensures hints is an array of hashes with 'name', and priority values are clamped to 0.0..1.0
|
|
1011
|
+
# @param prefs [Hash, nil] raw modelPreferences from request
|
|
1012
|
+
# @return [Hash, nil] normalized modelPreferences or nil
|
|
1013
|
+
def normalize_model_preferences(prefs)
|
|
1014
|
+
return nil if prefs.nil?
|
|
1015
|
+
return nil unless prefs.is_a?(Hash)
|
|
1016
|
+
|
|
1017
|
+
normalized = {}
|
|
1018
|
+
|
|
1019
|
+
# Normalize hints: array of { 'name' => String }
|
|
1020
|
+
if prefs['hints']
|
|
1021
|
+
normalized['hints'] = Array(prefs['hints']).filter_map do |hint|
|
|
1022
|
+
next nil unless hint.is_a?(Hash) && hint['name']
|
|
1023
|
+
|
|
1024
|
+
{ 'name' => hint['name'].to_s }
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# Normalize priority values (0.0 to 1.0)
|
|
1029
|
+
%w[costPriority speedPriority intelligencePriority].each do |key|
|
|
1030
|
+
next unless prefs.key?(key)
|
|
1031
|
+
|
|
1032
|
+
value = prefs[key]
|
|
1033
|
+
normalized[key] = value.is_a?(Numeric) ? value.to_f.clamp(0.0, 1.0) : nil
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
normalized
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Validate sampling response from handler (MCP 2025-11-25)
|
|
829
1040
|
# @param result [Hash] the result from the sampling handler
|
|
830
1041
|
# @return [Hash] validated sampling response
|
|
831
1042
|
def validate_sampling_response(result)
|