ruby_llm-mcp 0.8.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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -162
  3. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +21 -4
  4. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +20 -0
  5. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
  6. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
  7. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
  8. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
  9. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
  10. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
  11. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
  12. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +7 -1
  13. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +0 -3
  14. data/lib/ruby_llm/mcp/auth/browser/opener.rb +0 -2
  15. data/lib/ruby_llm/mcp/auth/browser/pages.rb +100 -32
  16. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +230 -57
  17. data/lib/ruby_llm/mcp/auth/discoverer.rb +157 -26
  18. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +19 -2
  19. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +3 -2
  20. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +0 -2
  21. data/lib/ruby_llm/mcp/auth/memory_storage.rb +31 -12
  22. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +124 -9
  23. data/lib/ruby_llm/mcp/auth/session_manager.rb +0 -2
  24. data/lib/ruby_llm/mcp/auth/token_manager.rb +74 -3
  25. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  26. data/lib/ruby_llm/mcp/auth/url_builder.rb +72 -15
  27. data/lib/ruby_llm/mcp/auth.rb +19 -7
  28. data/lib/ruby_llm/mcp/client.rb +267 -39
  29. data/lib/ruby_llm/mcp/configuration.rb +161 -12
  30. data/lib/ruby_llm/mcp/elicitation.rb +261 -14
  31. data/lib/ruby_llm/mcp/errors.rb +18 -0
  32. data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
  33. data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
  34. data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
  35. data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
  36. data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
  37. data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
  38. data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
  39. data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
  40. data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
  41. data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
  42. data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
  43. data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
  44. data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
  45. data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
  46. data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
  47. data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
  48. data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
  49. data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
  50. data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
  51. data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
  52. data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
  53. data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
  54. data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
  55. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
  56. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
  57. data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
  58. data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
  59. data/lib/ruby_llm/mcp/handlers.rb +14 -0
  60. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
  61. data/lib/ruby_llm/mcp/native/client.rb +551 -0
  62. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  63. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  64. data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
  65. data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
  66. data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
  67. data/lib/ruby_llm/mcp/native/messages.rb +43 -0
  68. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  69. data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
  70. data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
  71. data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
  72. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  73. data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
  74. data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
  75. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
  76. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  77. data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
  78. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  79. data/lib/ruby_llm/mcp/native.rb +12 -0
  80. data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
  81. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  82. data/lib/ruby_llm/mcp/railtie.rb +8 -6
  83. data/lib/ruby_llm/mcp/resource.rb +17 -8
  84. data/lib/ruby_llm/mcp/resource_template.rb +8 -7
  85. data/lib/ruby_llm/mcp/result.rb +8 -4
  86. data/lib/ruby_llm/mcp/roots.rb +4 -4
  87. data/lib/ruby_llm/mcp/sample.rb +83 -13
  88. data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
  89. data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
  90. data/lib/ruby_llm/mcp/task.rb +65 -0
  91. data/lib/ruby_llm/mcp/tool.rb +33 -27
  92. data/lib/ruby_llm/mcp/version.rb +1 -1
  93. data/lib/ruby_llm/mcp.rb +31 -7
  94. data/lib/tasks/smoke.rake +66 -0
  95. metadata +77 -36
  96. data/lib/ruby_llm/mcp/coordinator.rb +0 -304
  97. data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
  98. data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
  99. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
  100. data/lib/ruby_llm/mcp/protocol.rb +0 -34
  101. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
  102. data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
  103. data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
  104. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
  105. data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
  106. data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
  107. data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
  108. data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
  109. data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
  110. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
  111. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
  112. data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
  113. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
  114. data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
  115. data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
  116. data/lib/ruby_llm/mcp/response_handler.rb +0 -67
  117. data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
  118. data/lib/ruby_llm/mcp/responses/error.rb +0 -33
  119. data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
  120. data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
  121. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
  122. data/lib/ruby_llm/mcp/transport.rb +0 -151
  123. data/lib/ruby_llm/mcp/transports/sse.rb +0 -435
  124. data/lib/ruby_llm/mcp/transports/stdio.rb +0 -231
  125. data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -725
  126. data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
  127. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
  128. data/lib/ruby_llm/mcp/transports/support/timeout.rb +0 -34
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ module Transports
7
+ module Support
8
+ class HTTPClient
9
+ CONNECTION_KEY = :ruby_llm_mcp_client_connection
10
+
11
+ def self.connection
12
+ Thread.current[CONNECTION_KEY] ||= build_connection
13
+ end
14
+
15
+ def self.build_connection
16
+ HTTPX.with(
17
+ pool_options: {
18
+ max_connections: RubyLLM::MCP.config.max_connections,
19
+ pool_timeout: RubyLLM::MCP.config.pool_timeout
20
+ }
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ module Transports
7
+ module Support
8
+ class RateLimiter
9
+ def initialize(limit: 10, interval: 1000)
10
+ @limit = limit
11
+ @interval = interval
12
+ @timestamps = []
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def exceeded?
17
+ now = current_time
18
+
19
+ @mutex.synchronize do
20
+ purge_old(now)
21
+ @timestamps.size >= @limit
22
+ end
23
+ end
24
+
25
+ def add
26
+ now = current_time
27
+
28
+ @mutex.synchronize do
29
+ purge_old(now)
30
+ @timestamps << now
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def current_time
37
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
+ end
39
+
40
+ def purge_old(now)
41
+ cutoff = now - @interval
42
+ @timestamps.reject! { |t| t < cutoff }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ module Transports
7
+ module Support
8
+ module Timeout
9
+ def with_timeout(seconds, request_id: nil)
10
+ result = nil
11
+ exception = nil
12
+
13
+ worker = Thread.new do
14
+ result = yield
15
+ rescue StandardError => e
16
+ exception = e
17
+ end
18
+
19
+ if worker.join(seconds)
20
+ raise exception if exception
21
+
22
+ result
23
+ else
24
+ worker.kill # stop the thread (can still have some risk if shared resources)
25
+ raise RubyLLM::MCP::Errors::TimeoutError.new(
26
+ message: "Request timed out after #{seconds} seconds",
27
+ request_id: request_id
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "native/messages"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ # Native protocol implementation namespace
8
+ # This module contains the native Ruby MCP protocol implementation
9
+ module Native
10
+ end
11
+ end
12
+ end
@@ -3,11 +3,10 @@
3
3
  module RubyLLM
4
4
  module MCP
5
5
  class NotificationHandler
6
- attr_reader :coordinator, :client
6
+ attr_reader :client
7
7
 
8
- def initialize(coordinator)
9
- @coordinator = coordinator
10
- @client = coordinator.client
8
+ def initialize(client)
9
+ @client = client
11
10
  end
12
11
 
13
12
  def execute(notification)
@@ -25,7 +24,11 @@ module RubyLLM
25
24
  when "notifications/progress"
26
25
  process_progress_message(notification)
27
26
  when "notifications/cancelled"
28
- # TODO: - do nothing at the moment until we support client operations
27
+ process_cancelled_notification(notification)
28
+ when "notifications/tasks/status"
29
+ process_task_status_notification(notification)
30
+ when "notifications/elicitation/complete"
31
+ process_elicitation_complete_notification(notification)
29
32
  else
30
33
  process_unknown_notification(notification)
31
34
  end
@@ -75,10 +78,45 @@ module RubyLLM
75
78
  end
76
79
  end
77
80
 
81
+ def process_cancelled_notification(notification)
82
+ request_id = notification.params["requestId"]
83
+ reason = notification.params["reason"] || "No reason provided"
84
+
85
+ RubyLLM::MCP.logger.info(
86
+ "Received cancellation for request #{request_id}: #{reason}"
87
+ )
88
+
89
+ outcome = client.cancel_in_flight_request(request_id)
90
+
91
+ case outcome
92
+ when :cancelled, :already_cancelled, :already_completed
93
+ RubyLLM::MCP.logger.debug("Cancellation outcome for #{request_id}: #{outcome}")
94
+ when :not_found
95
+ RubyLLM::MCP.logger.debug("Request #{request_id} was not found or already completed")
96
+ when :not_cancellable
97
+ RubyLLM::MCP.logger.warn("Request #{request_id} is not cancellable")
98
+ else
99
+ RubyLLM::MCP.logger.warn("Cancellation for #{request_id} returned unexpected outcome: #{outcome.inspect}")
100
+ end
101
+ end
102
+
78
103
  def process_unknown_notification(notification)
79
104
  message = "Unknown notification type: #{notification.type} params: #{notification.params.to_h}"
80
105
  RubyLLM::MCP.logger.error(message)
81
106
  end
107
+
108
+ def process_task_status_notification(notification)
109
+ return unless client.adapter.respond_to?(:task_status_notification)
110
+
111
+ client.adapter.task_status_notification(task: notification.params)
112
+ end
113
+
114
+ def process_elicitation_complete_notification(notification)
115
+ elicitation_id = notification.params["elicitationId"]
116
+ return if elicitation_id.nil?
117
+
118
+ Handlers::ElicitationRegistry.remove(elicitation_id)
119
+ end
82
120
  end
83
121
  end
84
122
  end
@@ -21,10 +21,10 @@ module RubyLLM
21
21
  end
22
22
  end
23
23
 
24
- attr_reader :name, :description, :arguments, :coordinator
24
+ attr_reader :name, :description, :arguments, :adapter
25
25
 
26
- def initialize(coordinator, prompt)
27
- @coordinator = coordinator
26
+ def initialize(adapter, prompt)
27
+ @adapter = adapter
28
28
  @name = prompt["name"]
29
29
  @description = prompt["description"]
30
30
  @arguments = parse_arguments(prompt["arguments"])
@@ -51,8 +51,8 @@ module RubyLLM
51
51
  alias say ask
52
52
 
53
53
  def complete(argument, value, context: nil)
54
- if @coordinator.capabilities.completion?
55
- result = @coordinator.completion_prompt(name: @name, argument: argument, value: value, context: context)
54
+ if @adapter.capabilities.completion?
55
+ result = @adapter.completion_prompt(name: @name, argument: argument, value: value, context: context)
56
56
  if result.error?
57
57
  return result.to_error
58
58
  end
@@ -80,7 +80,7 @@ module RubyLLM
80
80
  private
81
81
 
82
82
  def fetch_prompt_messages(arguments)
83
- result = @coordinator.execute_prompt(
83
+ result = @adapter.execute_prompt(
84
84
  name: @name,
85
85
  arguments: arguments
86
86
  )
@@ -113,7 +113,7 @@ module RubyLLM
113
113
  attachment = MCP::Attachment.new(content["content"], content["mime_type"])
114
114
  MCP::Content.new(text: nil, attachments: [attachment])
115
115
  when "resource"
116
- resource = Resource.new(coordinator, content["resource"])
116
+ resource = Resource.new(adapter, content["resource"])
117
117
  resource.to_content
118
118
  end
119
119
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module RubyLLM
4
- module MCP
5
- class Railtie < Rails::Railtie
6
- generators do
7
- require_relative "../../generators/ruby_llm/mcp/install/install_generator"
8
- require_relative "../../generators/ruby_llm/mcp/oauth/install_generator"
3
+ if defined?(Rails::Railtie)
4
+ module RubyLLM
5
+ module MCP
6
+ class Railtie < Rails::Railtie
7
+ generators do
8
+ require_relative "../../generators/ruby_llm/mcp/install/install_generator"
9
+ require_relative "../../generators/ruby_llm/mcp/oauth/install_generator"
10
+ end
9
11
  end
10
12
  end
11
13
  end
@@ -1,18 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "httpx"
4
-
5
3
  module RubyLLM
6
4
  module MCP
7
5
  class Resource
8
- attr_reader :uri, :name, :description, :mime_type, :coordinator, :subscribed
6
+ attr_reader :uri, :name, :description, :mime_type, :adapter, :subscribed, :apps_metadata
9
7
 
10
- def initialize(coordinator, resource)
11
- @coordinator = coordinator
8
+ def initialize(adapter, resource)
9
+ @adapter = adapter
12
10
  @uri = resource["uri"]
13
11
  @name = resource["name"]
14
12
  @description = resource["description"]
15
13
  @mime_type = resource["mimeType"]
14
+ @apps_metadata = Extensions::Apps::ResourceMetadata.new(resource[Extensions::Apps::Constants::META_KEY])
16
15
  if resource.key?("content_response")
17
16
  @content_response = resource["content_response"]
18
17
  @content = @content_response["text"] || @content_response["blob"]
@@ -36,8 +35,8 @@ module RubyLLM
36
35
  end
37
36
 
38
37
  def subscribe!
39
- if @coordinator.capabilities.resource_subscribe?
40
- @coordinator.resources_subscribe(uri: @uri)
38
+ if @adapter.capabilities.resource_subscribe?
39
+ @adapter.resources_subscribe(uri: @uri)
41
40
  @subscribed = true
42
41
  else
43
42
  message = "Resource subscribe is not available for this MCP server"
@@ -45,6 +44,16 @@ module RubyLLM
45
44
  end
46
45
  end
47
46
 
47
+ def unsubscribe!
48
+ if @adapter.capabilities.resource_subscribe?
49
+ @adapter.resources_unsubscribe(uri: @uri)
50
+ @subscribed = false
51
+ else
52
+ message = "Resource unsubscribe is not available for this MCP server"
53
+ raise Errors::Capabilities::ResourceSubscribeNotAvailable.new(message: message)
54
+ end
55
+ end
56
+
48
57
  def reset_content!
49
58
  @content = nil
50
59
  @content_response = nil
@@ -101,7 +110,7 @@ module RubyLLM
101
110
  when "http", "https"
102
111
  fetch_uri_content(uri)
103
112
  else # file:// or git://
104
- @coordinator.resource_read(uri: uri)
113
+ @adapter.resource_read(uri: uri)
105
114
  end
106
115
  end
107
116
 
@@ -5,14 +5,15 @@ require "httpx"
5
5
  module RubyLLM
6
6
  module MCP
7
7
  class ResourceTemplate
8
- attr_reader :uri, :name, :description, :mime_type, :coordinator, :template
8
+ attr_reader :uri, :name, :description, :mime_type, :adapter, :template, :apps_metadata
9
9
 
10
- def initialize(coordinator, resource)
11
- @coordinator = coordinator
10
+ def initialize(adapter, resource)
11
+ @adapter = adapter
12
12
  @uri = resource["uriTemplate"]
13
13
  @name = resource["name"]
14
14
  @description = resource["description"]
15
15
  @mime_type = resource["mimeType"]
16
+ @apps_metadata = Extensions::Apps::ResourceMetadata.new(resource[Extensions::Apps::Constants::META_KEY])
16
17
  end
17
18
 
18
19
  def fetch_resource(arguments: {})
@@ -20,7 +21,7 @@ module RubyLLM
20
21
  result = read_response(uri)
21
22
  content_response = result.value.dig("contents", 0)
22
23
 
23
- Resource.new(coordinator, {
24
+ Resource.new(adapter, {
24
25
  "uri" => uri,
25
26
  "name" => "#{@name} (#{uri})",
26
27
  "description" => @description,
@@ -34,8 +35,8 @@ module RubyLLM
34
35
  end
35
36
 
36
37
  def complete(argument, value, context: nil)
37
- if @coordinator.capabilities.completion?
38
- result = @coordinator.completion_resource(uri: @uri, argument: argument, value: value, context: context)
38
+ if @adapter.capabilities.completion?
39
+ result = @adapter.completion_resource(uri: @uri, argument: argument, value: value, context: context)
39
40
  result.raise_error! if result.error?
40
41
 
41
42
  response = result.value["completion"]
@@ -64,7 +65,7 @@ module RubyLLM
64
65
  when "http", "https"
65
66
  fetch_uri_content(uri)
66
67
  else # file:// or git://
67
- @coordinator.resource_read(uri: uri)
68
+ @adapter.resource_read(uri: uri)
68
69
  end
69
70
  end
70
71
 
@@ -32,6 +32,10 @@ module RubyLLM
32
32
 
33
33
  @result_is_error = response.dig("result", "isError") || false
34
34
  @next_cursor = response.dig("result", "nextCursor")
35
+
36
+ # Track whether result/error keys exist (for JSON-RPC detection)
37
+ @has_result = response.key?("result")
38
+ @has_error = response.key?("error")
35
39
  end
36
40
 
37
41
  REQUEST_METHODS.each do |method_name, method_value|
@@ -65,7 +69,7 @@ module RubyLLM
65
69
  end
66
70
 
67
71
  def notification?
68
- @method&.include?("notifications") || false
72
+ @id.nil? && !@method.nil?
69
73
  end
70
74
 
71
75
  def next_cursor?
@@ -73,15 +77,15 @@ module RubyLLM
73
77
  end
74
78
 
75
79
  def request?
76
- !@method.nil? && !notification? && @result.none? && @error.none?
80
+ !@id.nil? && !@method.nil?
77
81
  end
78
82
 
79
83
  def response?
80
- !@id.nil? && (@result || @error.any?) && !@method
84
+ !@id.nil? && @method.nil? && (@has_result || @has_error)
81
85
  end
82
86
 
83
87
  def success?
84
- !@result.empty?
88
+ @has_result
85
89
  end
86
90
 
87
91
  def tool_success?
@@ -5,9 +5,9 @@ module RubyLLM
5
5
  class Roots
6
6
  attr_reader :paths
7
7
 
8
- def initialize(paths: [], coordinator: nil)
8
+ def initialize(paths: [], adapter: nil)
9
9
  @paths = paths
10
- @coordinator = coordinator
10
+ @adapter = adapter
11
11
  end
12
12
 
13
13
  def active?
@@ -16,12 +16,12 @@ module RubyLLM
16
16
 
17
17
  def add(path)
18
18
  @paths << path
19
- @coordinator.roots_list_change_notification
19
+ @adapter.roots_list_change_notification
20
20
  end
21
21
 
22
22
  def remove(path)
23
23
  @paths.delete(path)
24
- @coordinator.roots_list_change_notification
24
+ @adapter.roots_list_change_notification
25
25
  end
26
26
 
27
27
  def to_request
@@ -47,15 +47,14 @@ module RubyLLM
47
47
  end
48
48
 
49
49
  def execute
50
- return unless callback_guard_success?
50
+ # Check if handler is a class or block
51
+ handler = @coordinator.sampling_callback
51
52
 
52
- model = preferred_model
53
- return unless model
54
-
55
- chat_message = chat(model)
56
- @coordinator.sampling_create_message_response(
57
- id: @id, message: chat_message, model: model
58
- )
53
+ if Handlers.handler_class?(handler)
54
+ execute_with_handler_class(handler)
55
+ else
56
+ execute_with_block
57
+ end
59
58
  end
60
59
 
61
60
  def message
@@ -75,15 +74,86 @@ module RubyLLM
75
74
 
76
75
  private
77
76
 
78
- def callback_guard_success?
79
- return true unless @coordinator.client.sampling_callback_enabled?
77
+ # Execute using handler class
78
+ def execute_with_handler_class(handler_class)
79
+ handler_instance = handler_class.new(
80
+ sample: self,
81
+ coordinator: @coordinator
82
+ )
83
+
84
+ result = handler_instance.call
80
85
 
81
- unless @coordinator.client.on[:sampling].call(self)
86
+ # Handle different return types
87
+ case result
88
+ when Hash
89
+ handle_handler_hash_result(result)
90
+ when TrueClass, FalseClass
91
+ handle_handler_boolean_result(result)
92
+ else
93
+ # Unexpected return type
94
+ RubyLLM::MCP.logger.error("Handler returned unexpected type: #{result.class}")
95
+ @coordinator.error_response(id: @id, message: "Internal error in sampling handler")
96
+ end
97
+ rescue StandardError => e
98
+ RubyLLM::MCP.logger.error("Error in sampling handler: #{e.message}\n#{e.backtrace.join("\n")}")
99
+ @coordinator.error_response(id: @id, message: "Error executing sampling request: #{e.message}")
100
+ end
101
+
102
+ # Handle hash result from handler
103
+ def handle_handler_hash_result(result)
104
+ if result[:accepted] == false
105
+ @coordinator.error_response(id: @id, message: result[:message] || REJECTED_MESSAGE)
106
+ elsif result[:accepted] == true && result[:response]
107
+ # Handler provided the response directly
108
+ model = preferred_model
109
+ return unless model
110
+
111
+ @coordinator.sampling_create_message_response(
112
+ id: @id, message: result[:response], model: model
113
+ )
114
+ else
115
+ # Invalid hash structure
116
+ @coordinator.error_response(id: @id, message: "Invalid handler response")
117
+ end
118
+ end
119
+
120
+ # Handle boolean result from handler
121
+ def handle_handler_boolean_result(result)
122
+ unless result
82
123
  @coordinator.error_response(id: @id, message: REJECTED_MESSAGE)
83
- return false
124
+ return
84
125
  end
85
126
 
86
- true
127
+ model = preferred_model
128
+ return unless model
129
+
130
+ chat_message = chat(model)
131
+ @coordinator.sampling_create_message_response(
132
+ id: @id, message: chat_message, model: model
133
+ )
134
+ end
135
+
136
+ # Execute using block (legacy/backward compatible)
137
+ def execute_with_block
138
+ callback_result = run_sampling_callback
139
+
140
+ case callback_result
141
+ when Hash
142
+ handle_handler_hash_result(callback_result)
143
+ when TrueClass, FalseClass
144
+ handle_handler_boolean_result(callback_result)
145
+ else
146
+ # Legacy compatibility: any truthy callback result means "allow sampling"
147
+ handle_handler_boolean_result(!!callback_result)
148
+ end
149
+ end
150
+
151
+ def run_sampling_callback
152
+ return true unless @coordinator.sampling_callback_enabled?
153
+
154
+ callback_result = @coordinator.sampling_callback&.call(self)
155
+ # If callback returns nil, it means no guard was configured - allow it
156
+ callback_result.nil? || callback_result
87
157
  rescue StandardError => e
88
158
  RubyLLM::MCP.logger.error("Error in callback guard: #{e.message}, #{e.backtrace.join("\n")}")
89
159
  @coordinator.error_response(id: @id, message: "Error executing sampling request")
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_schemer"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module SchemaValidator
8
+ module_function
9
+
10
+ def valid?(schema, data)
11
+ return true if schema.nil?
12
+
13
+ schemer(schema).valid?(data)
14
+ rescue StandardError
15
+ false
16
+ end
17
+
18
+ def valid_schema?(schema)
19
+ return true if schema.nil?
20
+
21
+ schemer(schema)
22
+ true
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ def schemer(schema)
28
+ JSONSchemer.schema(schema)
29
+ end
30
+ private_class_method :schemer
31
+ end
32
+ end
33
+ end
@@ -44,6 +44,47 @@ module RubyLLM
44
44
  def logging?
45
45
  !@capabilities["logging"].nil?
46
46
  end
47
+
48
+ def tasks?
49
+ !@capabilities["tasks"].nil?
50
+ end
51
+
52
+ def tasks_list?
53
+ !@capabilities.dig("tasks", "list").nil?
54
+ end
55
+
56
+ def tasks_cancel?
57
+ !@capabilities.dig("tasks", "cancel").nil?
58
+ end
59
+
60
+ def task_augmented_tool_call?
61
+ !@capabilities.dig("tasks", "requests", "tools", "call").nil?
62
+ end
63
+
64
+ def extensions
65
+ value = @capabilities["extensions"]
66
+ value.is_a?(Hash) ? value : {}
67
+ end
68
+
69
+ def extension?(id)
70
+ !extension_capability(id).nil?
71
+ end
72
+
73
+ def extension_capability(id)
74
+ canonical_id = Extensions::Registry.canonicalize_id(id)
75
+ return nil if canonical_id.nil?
76
+
77
+ normalized_extensions[canonical_id]
78
+ end
79
+
80
+ private
81
+
82
+ def normalized_extensions
83
+ extensions.each_with_object({}) do |(id, value), acc|
84
+ canonical_id = Extensions::Registry.canonicalize_id(id) || id
85
+ acc[canonical_id] = value
86
+ end
87
+ end
47
88
  end
48
89
  end
49
90
  end