ruby-mcp-client 0.9.0 → 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.
@@ -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
@@ -16,13 +16,17 @@ module MCPClient
16
16
  # @return [Hash<String, MCPClient::Resource>] cache of resources by composite key (server_id:uri)
17
17
  # @!attribute [r] logger
18
18
  # @return [Logger] logger for client operations
19
- attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger
19
+ # @!attribute [r] roots
20
+ # @return [Array<MCPClient::Root>] list of MCP roots (MCP 2025-06-18)
21
+ attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots
20
22
 
21
23
  # Initialize a new MCPClient::Client
22
24
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
23
25
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
24
26
  # @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
25
- def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil)
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-11-25)
29
+ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
26
30
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
27
31
  @logger.progname = self.class.name
28
32
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
@@ -37,6 +41,10 @@ module MCPClient
37
41
  @notification_listeners = []
38
42
  # Elicitation handler (MCP 2025-06-18)
39
43
  @elicitation_handler = elicitation_handler
44
+ # Sampling handler (MCP 2025-11-25)
45
+ @sampling_handler = sampling_handler
46
+ # Roots (MCP 2025-06-18)
47
+ @roots = normalize_roots(roots)
40
48
  # Register default and user-defined notification handlers on each server
41
49
  @servers.each do |server|
42
50
  server.on_notification do |method, params|
@@ -49,6 +57,10 @@ module MCPClient
49
57
  if server.respond_to?(:on_elicitation_request)
50
58
  server.on_elicitation_request(&method(:handle_elicitation_request))
51
59
  end
60
+ # Register roots list handler on each server (MCP 2025-06-18)
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-11-25)
63
+ server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
52
64
  end
53
65
  end
54
66
 
@@ -330,6 +342,16 @@ module MCPClient
330
342
  @notification_listeners << block
331
343
  end
332
344
 
345
+ # Set the roots for this client (MCP 2025-06-18)
346
+ # When roots are changed, a notification is sent to all connected servers
347
+ # @param new_roots [Array<MCPClient::Root, Hash>] the new roots to set
348
+ # @return [void]
349
+ def roots=(new_roots)
350
+ @roots = normalize_roots(new_roots)
351
+ # Notify servers that roots have changed
352
+ notify_roots_changed
353
+ end
354
+
333
355
  # Find a server by name
334
356
  # @param name [String] the name of the server to find
335
357
  # @return [MCPClient::ServerBase, nil] the server with the given name, or nil if not found
@@ -353,8 +375,10 @@ module MCPClient
353
375
  end
354
376
 
355
377
  # Call multiple tools in batch
356
- # @param calls [Array<Hash>] array of calls in the form:
357
- # { name: tool_name, parameters: {...}, server: optional_server_name }
378
+ # @param calls [Array<Hash>] array of call hashes with keys:
379
+ # - name: tool name (required)
380
+ # - parameters: tool parameters (optional, default empty hash)
381
+ # - server: server name for routing (optional)
358
382
  # @return [Array<Object>] array of results for each tool invocation
359
383
  def call_tools(calls)
360
384
  calls.map do |call|
@@ -459,6 +483,100 @@ module MCPClient
459
483
  srv.rpc_notify(method, params)
460
484
  end
461
485
 
486
+ # Request completion suggestions from a server (MCP 2025-06-18)
487
+ # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
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
491
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
492
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
493
+ # @raise [MCPClient::Errors::ServerNotFound] if no server is available
494
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
495
+ def complete(ref:, argument:, context: nil, server: nil)
496
+ srv = select_server(server)
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
568
+ end
569
+
570
+ # Set the logging level on all connected servers (MCP 2025-06-18)
571
+ # To set on a specific server, use: client.find_server('name').log_level = 'debug'
572
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
573
+ # 'critical', 'alert', 'emergency')
574
+ # @return [Array<Hash>] results from servers
575
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
576
+ def log_level=(level)
577
+ @servers.map { |srv| srv.log_level = level }
578
+ end
579
+
462
580
  private
463
581
 
464
582
  # Process incoming JSON-RPC notifications with default handlers
@@ -480,12 +598,43 @@ module MCPClient
480
598
  when 'notifications/resources/list_changed'
481
599
  logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
482
600
  @resource_cache.clear
601
+ when 'notifications/message'
602
+ # MCP 2025-06-18: Handle logging messages from server
603
+ handle_log_message(server_id, params)
483
604
  else
484
605
  # Log unknown notification types for debugging purposes
485
606
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
486
607
  end
487
608
  end
488
609
 
610
+ # Handle logging message notification from server (MCP 2025-06-18)
611
+ # @param server_id [String] server identifier for log prefix
612
+ # @param params [Hash] log message params (level, logger, data)
613
+ # @return [void]
614
+ def handle_log_message(server_id, params)
615
+ level = params['level'] || 'info'
616
+ logger_name = params['logger']
617
+ data = params['data']
618
+
619
+ # Format the message
620
+ prefix = logger_name ? "[#{server_id}:#{logger_name}]" : "[#{server_id}]"
621
+ message = data.is_a?(String) ? data : data.inspect
622
+
623
+ # Map MCP log levels to Ruby Logger levels
624
+ case level.to_s.downcase
625
+ when 'debug'
626
+ logger.debug("#{prefix} #{message}")
627
+ when 'info', 'notice'
628
+ logger.info("#{prefix} #{message}")
629
+ when 'warning'
630
+ logger.warn("#{prefix} #{message}")
631
+ when 'error', 'critical', 'alert', 'emergency'
632
+ logger.error("#{prefix} #{message}")
633
+ else
634
+ logger.info("#{prefix} [#{level}] #{message}")
635
+ end
636
+ end
637
+
489
638
  # Select a server based on index, name, type, or instance
490
639
  # @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
491
640
  # @return [MCPClient::ServerBase]
@@ -529,7 +678,17 @@ module MCPClient
529
678
  required = schema['required'] || schema[:required]
530
679
  return unless required.is_a?(Array)
531
680
 
681
+ properties = schema['properties'] || schema[:properties] || {}
682
+
532
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
+
533
692
  return unless missing.any?
534
693
 
535
694
  raise MCPClient::Errors::ValidationError, "Missing required parameters: #{missing.join(', ')}"
@@ -606,8 +765,9 @@ module MCPClient
606
765
  end
607
766
  end
608
767
 
609
- # Handle elicitation request from server (MCP 2025-06-18)
610
- # @param request_id [String, Integer] the JSON-RPC request ID
768
+ # Handle elicitation request from server (MCP 2025-11-25)
769
+ # Supports both form mode (structured data) and URL mode (out-of-band interaction).
770
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
611
771
  # @param params [Hash] the elicitation parameters
612
772
  # @return [Hash] the elicitation response
613
773
  def handle_elicitation_request(_request_id, params)
@@ -617,43 +777,17 @@ module MCPClient
617
777
  return { 'action' => 'decline' }
618
778
  end
619
779
 
780
+ mode = params['mode'] || 'form'
620
781
  message = params['message']
621
- schema = params['schema'] || params['requestedSchema']
622
- metadata = params['metadata']
623
782
 
624
783
  begin
625
- # Call the user-defined handler
626
- result = case @elicitation_handler.arity
627
- when 0
628
- @elicitation_handler.call
629
- when 1
630
- @elicitation_handler.call(message)
631
- when 2, -1
632
- @elicitation_handler.call(message, schema)
784
+ result = if mode == 'url'
785
+ handle_url_elicitation(params, message)
633
786
  else
634
- @elicitation_handler.call(message, schema, metadata)
787
+ handle_form_elicitation(params, message)
635
788
  end
636
789
 
637
- # Validate and format response
638
- case result
639
- when Hash
640
- if result['action']
641
- normalised_action_response(result)
642
- elsif result[:action]
643
- # Convert symbol keys to strings
644
- {
645
- 'action' => result[:action].to_s,
646
- 'content' => result[:content]
647
- }.compact.then { |payload| normalised_action_response(payload) }
648
- else
649
- # Assume it's content for an accept action
650
- { 'action' => 'accept', 'content' => result }
651
- end
652
- when nil
653
- { 'action' => 'cancel' }
654
- else
655
- { 'action' => 'accept', 'content' => result }
656
- end
790
+ format_elicitation_response(result, params)
657
791
  rescue StandardError => e
658
792
  @logger.error("Elicitation handler error: #{e.message}")
659
793
  @logger.debug(e.backtrace.join("\n"))
@@ -661,6 +795,103 @@ module MCPClient
661
795
  end
662
796
  end
663
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
+
664
895
  # Ensure the action value conforms to MCP spec (accept, decline, cancel)
665
896
  # Falls back to accept for unknown action values.
666
897
  def normalised_action_response(result)
@@ -670,5 +901,169 @@ module MCPClient
670
901
  @logger.warn("Unknown elicitation action '#{action}', defaulting to accept")
671
902
  result.merge('action' => 'accept')
672
903
  end
904
+
905
+ # Normalize roots array - convert Hashes to Root objects (MCP 2025-06-18)
906
+ # @param roots [Array<MCPClient::Root, Hash>, nil] the roots to normalize
907
+ # @return [Array<MCPClient::Root>] normalized roots array
908
+ def normalize_roots(roots)
909
+ return [] if roots.nil?
910
+
911
+ roots.map do |root|
912
+ case root
913
+ when MCPClient::Root
914
+ root
915
+ when Hash
916
+ MCPClient::Root.from_json(root)
917
+ else
918
+ raise ArgumentError, "Invalid root type: #{root.class}. Expected MCPClient::Root or Hash."
919
+ end
920
+ end
921
+ end
922
+
923
+ # Handle roots/list request from server (MCP 2025-06-18)
924
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
925
+ # @param _params [Hash] the request parameters (unused)
926
+ # @return [Hash] the roots list response
927
+ def handle_roots_list_request(_request_id, _params)
928
+ { 'roots' => @roots.map(&:to_h) }
929
+ end
930
+
931
+ # Send notification to all servers that roots have changed (MCP 2025-06-18)
932
+ # @return [void]
933
+ def notify_roots_changed
934
+ @servers.each do |server|
935
+ server.rpc_notify('notifications/roots/list_changed', {})
936
+ rescue StandardError => e
937
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
938
+ @logger.warn("[#{server_id}] Failed to send roots/list_changed notification: #{e.message}")
939
+ end
940
+ end
941
+
942
+ # Handle sampling/createMessage request from server (MCP 2025-11-25)
943
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
944
+ # @param params [Hash] the sampling parameters
945
+ # @return [Hash] the sampling response (role, content, model, stopReason)
946
+ def handle_sampling_request(_request_id, params)
947
+ # If no handler is configured, return an error
948
+ unless @sampling_handler
949
+ @logger.warn('Received sampling request but no handler configured')
950
+ return { 'error' => { 'code' => -1, 'message' => 'Sampling not supported' } }
951
+ end
952
+
953
+ messages = params['messages'] || []
954
+ model_preferences = normalize_model_preferences(params['modelPreferences'])
955
+ system_prompt = params['systemPrompt']
956
+ max_tokens = params['maxTokens']
957
+ include_context = params['includeContext']
958
+ temperature = params['temperature']
959
+ stop_sequences = params['stopSequences']
960
+ metadata = params['metadata']
961
+
962
+ begin
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)
966
+
967
+ # Validate and format response
968
+ validate_sampling_response(result)
969
+ rescue StandardError => e
970
+ @logger.error("Sampling handler error: #{e.message}")
971
+ @logger.debug(e.backtrace.join("\n"))
972
+ { 'error' => { 'code' => -1, 'message' => "Sampling error: #{e.message}" } }
973
+ end
974
+ end
975
+
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)
1040
+ # @param result [Hash] the result from the sampling handler
1041
+ # @return [Hash] validated sampling response
1042
+ def validate_sampling_response(result)
1043
+ return { 'error' => { 'code' => -1, 'message' => 'Sampling rejected' } } if result.nil?
1044
+
1045
+ # Convert symbol keys to string keys
1046
+ result = result.transform_keys(&:to_s) if result.is_a?(Hash) && result.keys.first.is_a?(Symbol)
1047
+
1048
+ # Ensure required fields are present
1049
+ unless result.is_a?(Hash) && result['content']
1050
+ return {
1051
+ 'role' => 'assistant',
1052
+ 'content' => { 'type' => 'text', 'text' => result.to_s },
1053
+ 'model' => 'unknown',
1054
+ 'stopReason' => 'endTurn'
1055
+ }
1056
+ end
1057
+
1058
+ # Set defaults for missing fields
1059
+ result['role'] ||= 'assistant'
1060
+ result['model'] ||= 'unknown'
1061
+ result['stopReason'] ||= 'endTurn'
1062
+
1063
+ # Normalize content if it's a string
1064
+ result['content'] = { 'type' => 'text', 'text' => result['content'] } if result['content'].is_a?(String)
1065
+
1066
+ result
1067
+ end
673
1068
  end
674
1069
  end