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,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ # Promise implementation for async operations
7
+ class Promise
8
+ attr_reader :state, :value, :reason
9
+
10
+ # Initialize a new promise
11
+ def initialize
12
+ @state = :pending
13
+ @value = nil
14
+ @reason = nil
15
+ @mutex = Mutex.new
16
+ @condition = ConditionVariable.new
17
+ @then_callbacks = []
18
+ @catch_callbacks = []
19
+ end
20
+
21
+ # Register a callback for successful resolution
22
+ # @param callback [Proc] callback to execute on resolution
23
+ # @return [Promise] returns self for chaining
24
+ def then(&callback)
25
+ should_execute = false
26
+ value_to_use = nil
27
+
28
+ @mutex.synchronize do
29
+ if @state == :fulfilled
30
+ # Already fulfilled, will execute immediately
31
+ should_execute = true
32
+ value_to_use = @value
33
+ elsif @state == :pending
34
+ # Still pending, register callback
35
+ @then_callbacks << callback
36
+ end
37
+ end
38
+
39
+ # Execute outside mutex
40
+ execute_callback_sync(callback, value_to_use) if should_execute
41
+ self
42
+ end
43
+
44
+ # Register a callback for rejection
45
+ # @param callback [Proc] callback to execute on rejection
46
+ # @return [Promise] returns self for chaining
47
+ def catch(&callback)
48
+ should_execute = false
49
+ reason_to_use = nil
50
+
51
+ @mutex.synchronize do
52
+ if @state == :rejected
53
+ # Already rejected, will execute immediately
54
+ should_execute = true
55
+ reason_to_use = @reason
56
+ elsif @state == :pending
57
+ # Still pending, register callback
58
+ @catch_callbacks << callback
59
+ end
60
+ end
61
+
62
+ # Execute outside mutex
63
+ execute_callback_sync(callback, reason_to_use) if should_execute
64
+ self
65
+ end
66
+
67
+ # Resolve the promise with a value
68
+ # @param value [Object] the resolved value
69
+ def resolve(value)
70
+ callbacks_to_execute = nil
71
+
72
+ @mutex.synchronize do
73
+ return unless @state == :pending
74
+
75
+ @state = :fulfilled
76
+ @value = value
77
+
78
+ # Capture callbacks to execute outside mutex
79
+ callbacks_to_execute = @then_callbacks.dup
80
+ @then_callbacks.clear
81
+ @catch_callbacks.clear
82
+
83
+ # Signal waiting threads
84
+ @condition.broadcast
85
+ end
86
+
87
+ # Execute callbacks outside mutex to avoid deadlocks
88
+ # Use synchronous execution to maintain order and allow tests to work
89
+ callbacks_to_execute&.each do |callback|
90
+ execute_callback_sync(callback, value)
91
+ end
92
+ end
93
+
94
+ # Reject the promise with a reason
95
+ # @param reason [Object] the rejection reason
96
+ def reject(reason)
97
+ callbacks_to_execute = nil
98
+
99
+ @mutex.synchronize do
100
+ return unless @state == :pending
101
+
102
+ @state = :rejected
103
+ @reason = reason
104
+
105
+ # Capture callbacks to execute outside mutex
106
+ callbacks_to_execute = @catch_callbacks.dup
107
+ @then_callbacks.clear
108
+ @catch_callbacks.clear
109
+
110
+ # Signal waiting threads
111
+ @condition.broadcast
112
+ end
113
+
114
+ # Execute callbacks outside mutex to avoid deadlocks
115
+ # Use synchronous execution to maintain order and allow tests to work
116
+ callbacks_to_execute&.each do |callback|
117
+ execute_callback_sync(callback, reason)
118
+ end
119
+ end
120
+
121
+ # Check if promise is pending
122
+ def pending?
123
+ @state == :pending
124
+ end
125
+
126
+ # Check if promise is fulfilled
127
+ def fulfilled?
128
+ @state == :fulfilled
129
+ end
130
+
131
+ # Check if promise is rejected
132
+ def rejected?
133
+ @state == :rejected
134
+ end
135
+
136
+ # Check if promise is settled (fulfilled or rejected)
137
+ def settled?
138
+ !pending?
139
+ end
140
+
141
+ # Wait for promise to settle with optional timeout
142
+ # @param timeout [Numeric, nil] timeout in seconds
143
+ # @return [Object] resolved value or raises error
144
+ def wait(timeout: nil)
145
+ @mutex.synchronize do
146
+ # Wait until promise is settled
147
+ if timeout
148
+ deadline = Time.now + timeout
149
+ while pending?
150
+ remaining = deadline - Time.now
151
+ if remaining <= 0
152
+ raise Timeout::Error, "Promise timed out after #{timeout} seconds"
153
+ end
154
+
155
+ @condition.wait(@mutex, remaining)
156
+ end
157
+ else
158
+ @condition.wait(@mutex) while pending?
159
+ end
160
+
161
+ # Return value or raise error
162
+ return @value if fulfilled?
163
+ raise @reason if rejected?
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ # Execute a callback safely
170
+ # Note: Executes in background thread to avoid blocking
171
+ def execute_callback(callback, arg)
172
+ Thread.new do
173
+ callback.call(arg)
174
+ rescue StandardError => e
175
+ RubyLLM::MCP.logger.error(
176
+ "Error in promise callback: #{e.message}\n#{e.backtrace.join("\n")}"
177
+ )
178
+ end
179
+ end
180
+
181
+ # Execute a callback synchronously (for testing and immediate execution)
182
+ def execute_callback_sync(callback, arg)
183
+ callback.call(arg)
184
+ rescue StandardError => e
185
+ RubyLLM::MCP.logger.error(
186
+ "Error in promise callback: #{e.message}\n#{e.backtrace.join("\n")}"
187
+ )
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ # Base class for sampling request handlers
7
+ # Provides access to sample object, guards, and helper methods
8
+ #
9
+ # @example Basic sampling handler
10
+ # class MySamplingHandler < RubyLLM::MCP::Handlers::SamplingHandler
11
+ # def execute
12
+ # response = default_chat_completion("gpt-4")
13
+ # accept(response)
14
+ # end
15
+ # end
16
+ #
17
+ # @example Sampling handler with guards
18
+ # class GuardedSamplingHandler < RubyLLM::MCP::Handlers::SamplingHandler
19
+ # allow_models "gpt-4", "claude-3-opus"
20
+ #
21
+ # guard :check_model
22
+ # guard :check_token_limit
23
+ #
24
+ # def execute
25
+ # response = default_chat_completion(sample.model)
26
+ # accept(response)
27
+ # end
28
+ #
29
+ # private
30
+ #
31
+ # def check_model
32
+ # return true if model_allowed?(sample.model)
33
+ # "Model not allowed"
34
+ # end
35
+ #
36
+ # def check_token_limit
37
+ # return true if sample.max_tokens <= 4000
38
+ # "Too many tokens"
39
+ # end
40
+ # end
41
+ class SamplingHandler
42
+ include Concerns::Options
43
+ include Concerns::Lifecycle
44
+ include Concerns::Logging
45
+ include Concerns::ErrorHandling
46
+ include Concerns::GuardChecks
47
+ include Concerns::ModelFiltering
48
+ include Concerns::SamplingActions
49
+
50
+ attr_reader :coordinator
51
+
52
+ # Initialize sampling handler
53
+ # @param sample [RubyLLM::MCP::Sample] the sampling request
54
+ # @param coordinator [Object] the coordinator managing the request
55
+ # @param options [Hash] handler-specific options
56
+ def initialize(sample:, coordinator:, **options)
57
+ @sample = sample
58
+ @coordinator = coordinator
59
+ super(**options)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Handlers
6
+ # Detect if a handler is a class (vs a block/proc/lambda)
7
+ # @param handler [Object] the handler to check
8
+ # @return [Boolean] true if handler is a class
9
+ def self.handler_class?(handler)
10
+ handler.is_a?(Class) || (handler.respond_to?(:new) && !handler.respond_to?(:call))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ # Wraps server-initiated requests to support cancellation.
7
+ # The operation tracks terminal state so cancellation outcomes are explicit.
8
+ class CancellableOperation
9
+ attr_reader :request_id, :thread, :state
10
+
11
+ def initialize(request_id)
12
+ @request_id = request_id
13
+ @state = :pending
14
+ @mutex = Mutex.new
15
+ @thread = nil
16
+ @result = nil
17
+ @error = nil
18
+ end
19
+
20
+ def cancelled?
21
+ @mutex.synchronize { %i[cancelling cancelled].include?(@state) }
22
+ end
23
+
24
+ # @return [Symbol] :cancelled, :already_cancelled, :already_completed
25
+ def cancel
26
+ thread_to_cancel = nil
27
+
28
+ @mutex.synchronize do
29
+ case @state
30
+ when :cancelled, :cancelling
31
+ return :already_cancelled
32
+ when :completed
33
+ return :already_completed
34
+ when :pending
35
+ @state = :cancelled
36
+ return :cancelled
37
+ when :running
38
+ @state = :cancelling
39
+ thread_to_cancel = @thread
40
+ end
41
+ end
42
+
43
+ if thread_to_cancel&.alive?
44
+ thread_to_cancel.raise(
45
+ Errors::RequestCancelled.new(
46
+ message: "Request #{@request_id} was cancelled",
47
+ request_id: @request_id
48
+ )
49
+ )
50
+ else
51
+ @mutex.synchronize do
52
+ @state = :cancelled if @state == :cancelling
53
+ end
54
+ end
55
+
56
+ :cancelled
57
+ end
58
+
59
+ # Execute a block in a separate thread so cancellation can interrupt execution.
60
+ # Returns the block result or re-raises non-cancellation exceptions.
61
+ def execute(&)
62
+ return nil if cancelled?
63
+
64
+ @mutex.synchronize do
65
+ return nil if %i[cancelled cancelling].include?(@state)
66
+
67
+ @state = :running
68
+ end
69
+
70
+ @thread = Thread.new do
71
+ Thread.current.abort_on_exception = false
72
+ begin
73
+ @result = yield
74
+ rescue Errors::RequestCancelled, StandardError => e
75
+ @error = e
76
+ end
77
+ end
78
+
79
+ @thread.join
80
+ raise @error if @error && !@error.is_a?(Errors::RequestCancelled)
81
+
82
+ @result
83
+ ensure
84
+ @mutex.synchronize do
85
+ if @state == :running || @state == :cancelling
86
+ @state = @error.is_a?(Errors::RequestCancelled) ? :cancelled : :completed
87
+ end
88
+ @thread = nil
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end