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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79251746d1952caddd00b917b914d5ddb47c742c960914a39bf6a7f7a7073d35
4
- data.tar.gz: 98522398f4bcb491c59d794f6e869d9570c0ddfc3fba40eb584e21079ea913e2
3
+ metadata.gz: e7e861ecba26ec407886fbb6d169b85c78ecba0768552b512cbe6c9c1f6216ad
4
+ data.tar.gz: d71bab971376849e2bf72e2598116087cdb4f072c556368471d1d9adfe1bd7b5
5
5
  SHA512:
6
- metadata.gz: bb37b670e9e54baf9dfc280d0be09047e43236439ae94f0fd8acb1277a0acc1f0eba33661a3d1fa49c25d11493b6a70c283be66fab91420a1ba2b2fbe7360499
7
- data.tar.gz: 840c2a9685114762396106e5e7cc327ac2e0c3639575cf4eb3f4a41b50d6bc3c19a705d5405bcd1f7504fa1961c3f5ce5796105e77d780b53515255a3151539c
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-06-18** specification:
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-06-18 Features
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
@@ -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-06-18)
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-06-18)
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-06-18)
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-06-18)
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
- # Call the user-defined handler
703
- result = case @elicitation_handler.arity
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
- @elicitation_handler.call(message, schema, metadata)
787
+ handle_form_elicitation(params, message)
712
788
  end
713
789
 
714
- # Validate and format response
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-06-18)
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 (messages, modelPreferences, systemPrompt, maxTokens)
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 = case @sampling_handler.arity
807
- when 0
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
- # Validate sampling response from handler (MCP 2025-06-18)
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)