ruby_llm-mcp 0.7.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.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -162
  3. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  4. data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +21 -4
  5. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  19. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +215 -0
  20. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +413 -0
  21. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +41 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +56 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +56 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +90 -0
  25. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +216 -0
  26. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  27. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +36 -0
  28. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  29. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  30. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  31. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +427 -0
  32. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  33. data/lib/ruby_llm/mcp/auth/discoverer.rb +255 -0
  34. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +122 -0
  35. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +67 -0
  36. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  37. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  38. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  39. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  40. data/lib/ruby_llm/mcp/auth/memory_storage.rb +91 -0
  41. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +341 -0
  42. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  43. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  44. data/lib/ruby_llm/mcp/auth/token_manager.rb +307 -0
  45. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  46. data/lib/ruby_llm/mcp/auth/url_builder.rb +135 -0
  47. data/lib/ruby_llm/mcp/auth.rb +371 -0
  48. data/lib/ruby_llm/mcp/client.rb +312 -35
  49. data/lib/ruby_llm/mcp/configuration.rb +199 -24
  50. data/lib/ruby_llm/mcp/elicitation.rb +261 -14
  51. data/lib/ruby_llm/mcp/errors.rb +29 -0
  52. data/lib/ruby_llm/mcp/extensions/apps/constants.rb +28 -0
  53. data/lib/ruby_llm/mcp/extensions/apps/resource_metadata.rb +24 -0
  54. data/lib/ruby_llm/mcp/extensions/apps/tool_metadata.rb +45 -0
  55. data/lib/ruby_llm/mcp/extensions/configuration.rb +72 -0
  56. data/lib/ruby_llm/mcp/extensions/constants.rb +16 -0
  57. data/lib/ruby_llm/mcp/extensions/registry.rb +85 -0
  58. data/lib/ruby_llm/mcp/handlers/approval_decision.rb +90 -0
  59. data/lib/ruby_llm/mcp/handlers/async_response.rb +181 -0
  60. data/lib/ruby_llm/mcp/handlers/concerns/approval_actions.rb +42 -0
  61. data/lib/ruby_llm/mcp/handlers/concerns/async_execution.rb +80 -0
  62. data/lib/ruby_llm/mcp/handlers/concerns/elicitation_actions.rb +42 -0
  63. data/lib/ruby_llm/mcp/handlers/concerns/error_handling.rb +29 -0
  64. data/lib/ruby_llm/mcp/handlers/concerns/guard_checks.rb +72 -0
  65. data/lib/ruby_llm/mcp/handlers/concerns/lifecycle.rb +84 -0
  66. data/lib/ruby_llm/mcp/handlers/concerns/logging.rb +19 -0
  67. data/lib/ruby_llm/mcp/handlers/concerns/model_filtering.rb +36 -0
  68. data/lib/ruby_llm/mcp/handlers/concerns/options.rb +83 -0
  69. data/lib/ruby_llm/mcp/handlers/concerns/registry_integration.rb +54 -0
  70. data/lib/ruby_llm/mcp/handlers/concerns/sampling_actions.rb +84 -0
  71. data/lib/ruby_llm/mcp/handlers/concerns/timeouts.rb +52 -0
  72. data/lib/ruby_llm/mcp/handlers/concerns/tool_filtering.rb +50 -0
  73. data/lib/ruby_llm/mcp/handlers/elicitation_handler.rb +58 -0
  74. data/lib/ruby_llm/mcp/handlers/elicitation_registry.rb +203 -0
  75. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_handler.rb +93 -0
  76. data/lib/ruby_llm/mcp/handlers/human_in_the_loop_registry.rb +271 -0
  77. data/lib/ruby_llm/mcp/handlers/promise.rb +192 -0
  78. data/lib/ruby_llm/mcp/handlers/sampling_handler.rb +64 -0
  79. data/lib/ruby_llm/mcp/handlers.rb +14 -0
  80. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +94 -0
  81. data/lib/ruby_llm/mcp/native/client.rb +551 -0
  82. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  83. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  84. data/lib/ruby_llm/mcp/native/messages/notifications.rb +60 -0
  85. data/lib/ruby_llm/mcp/native/messages/requests.rb +267 -0
  86. data/lib/ruby_llm/mcp/native/messages/responses.rb +114 -0
  87. data/lib/ruby_llm/mcp/native/messages.rb +43 -0
  88. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  89. data/lib/ruby_llm/mcp/native/protocol.rb +79 -0
  90. data/lib/ruby_llm/mcp/native/response_handler.rb +220 -0
  91. data/lib/ruby_llm/mcp/native/task_registry.rb +62 -0
  92. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  93. data/lib/ruby_llm/mcp/native/transports/sse.rb +655 -0
  94. data/lib/ruby_llm/mcp/native/transports/stdio.rb +367 -0
  95. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +1024 -0
  96. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  97. data/lib/ruby_llm/mcp/native/transports/support/rate_limiter.rb +49 -0
  98. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  99. data/lib/ruby_llm/mcp/native.rb +12 -0
  100. data/lib/ruby_llm/mcp/notification_handler.rb +43 -5
  101. data/lib/ruby_llm/mcp/prompt.rb +7 -7
  102. data/lib/ruby_llm/mcp/railtie.rb +7 -13
  103. data/lib/ruby_llm/mcp/resource.rb +17 -8
  104. data/lib/ruby_llm/mcp/resource_template.rb +8 -7
  105. data/lib/ruby_llm/mcp/result.rb +8 -4
  106. data/lib/ruby_llm/mcp/roots.rb +4 -4
  107. data/lib/ruby_llm/mcp/sample.rb +83 -13
  108. data/lib/ruby_llm/mcp/schema_validator.rb +33 -0
  109. data/lib/ruby_llm/mcp/server_capabilities.rb +41 -0
  110. data/lib/ruby_llm/mcp/task.rb +65 -0
  111. data/lib/ruby_llm/mcp/tool.rb +33 -27
  112. data/lib/ruby_llm/mcp/version.rb +1 -1
  113. data/lib/ruby_llm/mcp.rb +37 -7
  114. data/lib/tasks/smoke.rake +66 -0
  115. metadata +115 -39
  116. data/lib/generators/ruby_llm/mcp/templates/mcps.yml +0 -9
  117. data/lib/ruby_llm/mcp/coordinator.rb +0 -293
  118. data/lib/ruby_llm/mcp/notifications/cancelled.rb +0 -32
  119. data/lib/ruby_llm/mcp/notifications/initialize.rb +0 -24
  120. data/lib/ruby_llm/mcp/notifications/roots_list_change.rb +0 -26
  121. data/lib/ruby_llm/mcp/protocol.rb +0 -34
  122. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +0 -50
  123. data/lib/ruby_llm/mcp/requests/completion_resource.rb +0 -50
  124. data/lib/ruby_llm/mcp/requests/initialization.rb +0 -34
  125. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +0 -28
  126. data/lib/ruby_llm/mcp/requests/ping.rb +0 -24
  127. data/lib/ruby_llm/mcp/requests/prompt_call.rb +0 -32
  128. data/lib/ruby_llm/mcp/requests/prompt_list.rb +0 -31
  129. data/lib/ruby_llm/mcp/requests/resource_list.rb +0 -31
  130. data/lib/ruby_llm/mcp/requests/resource_read.rb +0 -30
  131. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +0 -31
  132. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +0 -30
  133. data/lib/ruby_llm/mcp/requests/shared/meta.rb +0 -32
  134. data/lib/ruby_llm/mcp/requests/shared/pagination.rb +0 -17
  135. data/lib/ruby_llm/mcp/requests/tool_call.rb +0 -35
  136. data/lib/ruby_llm/mcp/requests/tool_list.rb +0 -31
  137. data/lib/ruby_llm/mcp/response_handler.rb +0 -67
  138. data/lib/ruby_llm/mcp/responses/elicitation.rb +0 -33
  139. data/lib/ruby_llm/mcp/responses/error.rb +0 -33
  140. data/lib/ruby_llm/mcp/responses/ping.rb +0 -28
  141. data/lib/ruby_llm/mcp/responses/roots_list.rb +0 -31
  142. data/lib/ruby_llm/mcp/responses/sampling_create_message.rb +0 -50
  143. data/lib/ruby_llm/mcp/transport.rb +0 -58
  144. data/lib/ruby_llm/mcp/transports/sse.rb +0 -341
  145. data/lib/ruby_llm/mcp/transports/stdio.rb +0 -230
  146. data/lib/ruby_llm/mcp/transports/streamable_http.rb +0 -723
  147. data/lib/ruby_llm/mcp/transports/support/http_client.rb +0 -28
  148. data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +0 -47
  149. data/lib/ruby_llm/mcp/transports/support/timeout.rb +0 -34
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Native
6
+ # Native MCP protocol client implementation
7
+ # This is the core protocol implementation that handles all MCP operations
8
+ # It's self-contained and could potentially be extracted as a separate gem
9
+ class Client
10
+ TOOL_CALL_CANCELLED_MESSAGE = "Tool call was cancelled by the client"
11
+
12
+ attr_reader :name, :transport_type, :config, :capabilities, :protocol_version, :elicitation_callback,
13
+ :sampling_callback, :human_in_the_loop_registry, :registry_owner_id, :task_registry
14
+
15
+ def initialize( # rubocop:disable Metrics/ParameterLists
16
+ name:,
17
+ transport_type:,
18
+ transport_config: {},
19
+ human_in_the_loop_callback: nil,
20
+ roots_callback: nil,
21
+ logging_enabled: false,
22
+ logging_level: nil,
23
+ elicitation_enabled: false,
24
+ elicitation_callback: nil,
25
+ progress_tracking_enabled: false,
26
+ sampling_callback: nil,
27
+ notification_callback: nil,
28
+ extensions_capabilities: nil,
29
+ protocol_version: nil,
30
+ request_timeout: nil
31
+ )
32
+ @name = name
33
+ @transport_type = transport_type
34
+ @config = transport_config.merge(request_timeout: request_timeout || MCP.config.request_timeout)
35
+ @requested_protocol_version = protocol_version || MCP.config.protocol_version || Native::Protocol.latest_version
36
+ @protocol_version = @requested_protocol_version
37
+ @extensions_capabilities = extensions_capabilities || {}
38
+
39
+ # Callbacks
40
+ @human_in_the_loop_callback = human_in_the_loop_callback
41
+ @roots_callback = roots_callback
42
+ @logging_enabled = logging_enabled
43
+ @logging_level = logging_level
44
+ @elicitation_enabled = elicitation_enabled
45
+ @elicitation_callback = elicitation_callback
46
+ @progress_tracking_enabled = progress_tracking_enabled
47
+ @sampling_callback = sampling_callback
48
+ @notification_callback = notification_callback
49
+
50
+ @transport = nil
51
+ @capabilities = nil
52
+ @task_registry = Native::TaskRegistry.new
53
+
54
+ # Track in-flight server-initiated requests for cancellation
55
+ @in_flight_requests = {}
56
+ @in_flight_mutex = Mutex.new
57
+
58
+ # Human-in-the-loop approvals are scoped per client lifecycle.
59
+ @registry_owner_id = "native-client-#{SecureRandom.uuid}"
60
+ @human_in_the_loop_registry = Handlers::HumanInTheLoopRegistry.for_owner(@registry_owner_id)
61
+ end
62
+
63
+ def request(body, **options)
64
+ transport.request(body, **options)
65
+ rescue RubyLLM::MCP::Errors::TimeoutError => e
66
+ if transport&.alive? && !e.request_id.nil?
67
+ cancelled_notification(reason: "Request timed out", request_id: e.request_id)
68
+ end
69
+ raise e
70
+ end
71
+
72
+ def process_result(result)
73
+ if result.notification?
74
+ process_notification(result)
75
+ return nil
76
+ end
77
+
78
+ if result.request?
79
+ process_request(result) if alive?
80
+ return nil
81
+ end
82
+
83
+ if result.response?
84
+ return result
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ def start
91
+ return unless capabilities.nil?
92
+
93
+ transport.start
94
+
95
+ initialize_response = initialize_request
96
+ initialize_response.raise_error! if initialize_response.error?
97
+
98
+ # Extract and store the negotiated protocol version
99
+ negotiated_version = initialize_response.value["protocolVersion"]
100
+
101
+ if negotiated_version && !Native::Protocol.supported_version?(negotiated_version)
102
+ raise Errors::UnsupportedProtocolVersion.new(
103
+ message: <<~MESSAGE
104
+ Unsupported protocol version, and could not negotiate a supported version: #{negotiated_version}.
105
+ Supported versions: #{Native::Protocol.supported_versions.join(', ')}
106
+ MESSAGE
107
+ )
108
+ end
109
+
110
+ @protocol_version = negotiated_version if negotiated_version
111
+
112
+ # Set the protocol version on the transport for subsequent requests
113
+ if @transport.respond_to?(:set_protocol_version)
114
+ @transport.set_protocol_version(@protocol_version)
115
+ end
116
+
117
+ @capabilities = RubyLLM::MCP::ServerCapabilities.new(initialize_response.value["capabilities"])
118
+ initialize_notification
119
+
120
+ if @logging_enabled && @logging_level
121
+ set_logging(level: @logging_level)
122
+ end
123
+ end
124
+
125
+ def stop
126
+ @transport&.close
127
+ @capabilities = nil
128
+ @transport = nil
129
+ @task_registry = Native::TaskRegistry.new
130
+ @protocol_version = @requested_protocol_version || MCP.config.protocol_version || Native::Protocol.latest_version
131
+ Handlers::HumanInTheLoopRegistry.release(@registry_owner_id)
132
+ @human_in_the_loop_registry = Handlers::HumanInTheLoopRegistry.for_owner(@registry_owner_id)
133
+ end
134
+
135
+ def restart!
136
+ @initialize_response = nil
137
+ stop
138
+ start
139
+ end
140
+
141
+ def alive?
142
+ !!@transport&.alive?
143
+ end
144
+
145
+ def ping
146
+ body = Native::Messages::Requests.ping(tracking_progress: tracking_progress?)
147
+ if alive?
148
+ result = request(body)
149
+ else
150
+ transport.start
151
+
152
+ result = request(body)
153
+ @transport = nil
154
+ end
155
+
156
+ result.value == {}
157
+ rescue RubyLLM::MCP::Errors::TimeoutError, RubyLLM::MCP::Errors::TransportError
158
+ false
159
+ end
160
+
161
+ def process_notification(result)
162
+ notification = result.notification
163
+ @notification_callback&.call(notification)
164
+ end
165
+
166
+ def process_request(result)
167
+ Native::ResponseHandler.new(self).execute(result)
168
+ end
169
+
170
+ def initialize_request
171
+ body = Native::Messages::Requests.initialize(
172
+ protocol_version: protocol_version,
173
+ capabilities: client_capabilities
174
+ )
175
+ request(body)
176
+ end
177
+
178
+ def tool_list(cursor: nil)
179
+ body = Native::Messages::Requests.tool_list(cursor: cursor, tracking_progress: tracking_progress?)
180
+ result = request(body)
181
+ result.raise_error! if result.error?
182
+
183
+ if result.next_cursor?
184
+ result.value["tools"] + tool_list(cursor: result.next_cursor)
185
+ else
186
+ result.value["tools"]
187
+ end
188
+ end
189
+
190
+ def execute_tool(name:, parameters:)
191
+ if @human_in_the_loop_callback
192
+ approved = evaluate_tool_approval(name: name, parameters: parameters)
193
+ return create_cancelled_result unless approved
194
+ end
195
+
196
+ body = Native::Messages::Requests.tool_call(name: name, parameters: parameters,
197
+ tracking_progress: tracking_progress?)
198
+ request(body)
199
+ end
200
+
201
+ def resource_list(cursor: nil)
202
+ body = Native::Messages::Requests.resource_list(cursor: cursor, tracking_progress: tracking_progress?)
203
+ result = request(body)
204
+ result.raise_error! if result.error?
205
+
206
+ if result.next_cursor?
207
+ result.value["resources"] + resource_list(cursor: result.next_cursor)
208
+ else
209
+ result.value["resources"]
210
+ end
211
+ end
212
+
213
+ def resource_read(uri:)
214
+ body = Native::Messages::Requests.resource_read(uri: uri, tracking_progress: tracking_progress?)
215
+ request(body)
216
+ end
217
+
218
+ def resource_template_list(cursor: nil)
219
+ body = Native::Messages::Requests.resource_template_list(cursor: cursor,
220
+ tracking_progress: tracking_progress?)
221
+ result = request(body)
222
+ result.raise_error! if result.error?
223
+
224
+ if result.next_cursor?
225
+ result.value["resourceTemplates"] + resource_template_list(cursor: result.next_cursor)
226
+ else
227
+ result.value["resourceTemplates"]
228
+ end
229
+ end
230
+
231
+ def resources_subscribe(uri:)
232
+ body = Native::Messages::Requests.resources_subscribe(uri: uri, tracking_progress: tracking_progress?)
233
+ request(body, wait_for_response: false)
234
+ end
235
+
236
+ def resources_unsubscribe(uri:)
237
+ body = Native::Messages::Requests.resources_unsubscribe(uri: uri, tracking_progress: tracking_progress?)
238
+ request(body, wait_for_response: false)
239
+ end
240
+
241
+ def prompt_list(cursor: nil)
242
+ body = Native::Messages::Requests.prompt_list(cursor: cursor, tracking_progress: tracking_progress?)
243
+ result = request(body)
244
+ result.raise_error! if result.error?
245
+
246
+ if result.next_cursor?
247
+ result.value["prompts"] + prompt_list(cursor: result.next_cursor)
248
+ else
249
+ result.value["prompts"]
250
+ end
251
+ end
252
+
253
+ def execute_prompt(name:, arguments:)
254
+ body = Native::Messages::Requests.prompt_call(name: name, arguments: arguments,
255
+ tracking_progress: tracking_progress?)
256
+ request(body)
257
+ end
258
+
259
+ def completion_resource(uri:, argument:, value:, context: nil)
260
+ body = Native::Messages::Requests.completion_resource(uri: uri, argument: argument, value: value,
261
+ context: context, tracking_progress: tracking_progress?)
262
+ request(body)
263
+ end
264
+
265
+ def completion_prompt(name:, argument:, value:, context: nil)
266
+ body = Native::Messages::Requests.completion_prompt(name: name, argument: argument, value: value,
267
+ context: context, tracking_progress: tracking_progress?)
268
+ request(body)
269
+ end
270
+
271
+ def set_logging(level:)
272
+ body = Native::Messages::Requests.logging_set_level(level: level, tracking_progress: tracking_progress?)
273
+ request(body)
274
+ end
275
+
276
+ def tasks_list(cursor: nil)
277
+ body = Native::Messages::Requests.tasks_list(cursor: cursor, tracking_progress: tracking_progress?)
278
+ result = request(body)
279
+ result.raise_error! if result.error?
280
+
281
+ task_registry.upsert_many(result.value["tasks"])
282
+
283
+ if result.next_cursor?
284
+ result.value["tasks"] + tasks_list(cursor: result.next_cursor)
285
+ else
286
+ result.value["tasks"] || []
287
+ end
288
+ end
289
+
290
+ def task_get(task_id:)
291
+ body = Native::Messages::Requests.task_get(task_id: task_id, tracking_progress: tracking_progress?)
292
+ result = request(body)
293
+ result.raise_error! if result.error?
294
+
295
+ task_registry.upsert(result.value)
296
+ result
297
+ end
298
+
299
+ def task_result(task_id:)
300
+ body = Native::Messages::Requests.task_result(task_id: task_id, tracking_progress: tracking_progress?)
301
+ result = request(body)
302
+ result.raise_error! if result.error?
303
+
304
+ task_registry.store_payload(task_id, result.value)
305
+ result
306
+ end
307
+
308
+ def task_cancel(task_id:)
309
+ body = Native::Messages::Requests.task_cancel(task_id: task_id, tracking_progress: tracking_progress?)
310
+ result = request(body)
311
+ result.raise_error! if result.error?
312
+
313
+ task_registry.upsert(result.value)
314
+ result
315
+ end
316
+
317
+ def task_status_notification(task:)
318
+ task_registry.upsert(task)
319
+ end
320
+
321
+ def set_progress_tracking(enabled:)
322
+ @progress_tracking_enabled = enabled
323
+ end
324
+
325
+ def set_elicitation_enabled(enabled:)
326
+ @elicitation_enabled = enabled
327
+ end
328
+
329
+ ## Notifications
330
+ #
331
+ def initialize_notification
332
+ body = Native::Messages::Notifications.initialized
333
+ request(body, wait_for_response: false)
334
+ end
335
+
336
+ def cancelled_notification(reason:, request_id:)
337
+ body = Native::Messages::Notifications.cancelled(request_id: request_id, reason: reason)
338
+ request(body, wait_for_response: false)
339
+ end
340
+
341
+ def roots_list_change_notification
342
+ body = Native::Messages::Notifications.roots_list_changed
343
+ request(body, wait_for_response: false)
344
+ end
345
+
346
+ ## Responses
347
+ #
348
+ def ping_response(id:)
349
+ body = Native::Messages::Responses.ping(id: id)
350
+ request(body, wait_for_response: false)
351
+ end
352
+
353
+ def roots_list_response(id:)
354
+ body = Native::Messages::Responses.roots_list(id: id, roots_paths: roots_paths)
355
+ request(body, wait_for_response: false)
356
+ end
357
+
358
+ def result_response(id:, value:)
359
+ body = Native::Messages::Responses.result(id: id, value: value)
360
+ request(body, wait_for_response: false)
361
+ end
362
+
363
+ def sampling_create_message_response(id:, model:, message:, **_options)
364
+ body = Native::Messages::Responses.sampling_create_message(id: id, model: model, message: message)
365
+ request(body, wait_for_response: false)
366
+ end
367
+
368
+ def error_response(id:, message:, code: Native::JsonRpc::ErrorCodes::SERVER_ERROR, data: nil)
369
+ body = Native::Messages::Responses.error(id: id, message: message, code: code, data: data)
370
+ request(body, wait_for_response: false)
371
+ end
372
+
373
+ def elicitation_response(id:, elicitation:)
374
+ body = Native::Messages::Responses.elicitation(id: id, action: elicitation[:action],
375
+ content: elicitation[:content])
376
+ request(body, wait_for_response: false)
377
+ end
378
+
379
+ def client_capabilities
380
+ capabilities_hash = {}
381
+
382
+ if @roots_callback&.call&.any?
383
+ capabilities_hash[:roots] = {
384
+ listChanged: true
385
+ }
386
+ end
387
+
388
+ if MCP.config.sampling.enabled?
389
+ sampling_capabilities = {}
390
+ sampling_capabilities[:tools] = {} if MCP.config.sampling.tools
391
+ sampling_capabilities[:context] = {} if MCP.config.sampling.context
392
+ capabilities_hash[:sampling] = sampling_capabilities
393
+ end
394
+
395
+ if @elicitation_enabled
396
+ elicitation_capabilities = {}
397
+ elicitation_capabilities[:form] = {} if MCP.config.elicitation.form
398
+ elicitation_capabilities[:url] = {} if MCP.config.elicitation.url
399
+ capabilities_hash[:elicitation] = elicitation_capabilities unless elicitation_capabilities.empty?
400
+ end
401
+
402
+ if MCP.config.respond_to?(:tasks) && MCP.config.tasks.enabled?
403
+ capabilities_hash[:tasks] = {
404
+ list: {},
405
+ cancel: {}
406
+ }
407
+ end
408
+
409
+ if @extensions_capabilities.any? && Native::Protocol.extensions_supported?(@protocol_version)
410
+ capabilities_hash[:extensions] = @extensions_capabilities
411
+ end
412
+
413
+ capabilities_hash
414
+ end
415
+
416
+ def roots_paths
417
+ @roots_callback&.call || []
418
+ end
419
+
420
+ def tracking_progress?
421
+ @progress_tracking_enabled
422
+ end
423
+
424
+ def sampling_callback_enabled?
425
+ !@sampling_callback.nil?
426
+ end
427
+
428
+ def transport
429
+ @transport ||= Native::Transport.new(@transport_type, self, config: @config)
430
+ end
431
+
432
+ # Register a server-initiated request that can be cancelled
433
+ # @param request_id [String] The ID of the request
434
+ # @param cancellable_operation [CancellableOperation, nil] The operation that can be cancelled
435
+ def register_in_flight_request(request_id, cancellable_operation = nil)
436
+ @in_flight_mutex.synchronize do
437
+ @in_flight_requests[request_id.to_s] = cancellable_operation
438
+ end
439
+ end
440
+
441
+ # Unregister a completed or cancelled request
442
+ # @param request_id [String] The ID of the request
443
+ def unregister_in_flight_request(request_id)
444
+ @in_flight_mutex.synchronize do
445
+ @in_flight_requests.delete(request_id.to_s)
446
+ end
447
+ end
448
+
449
+ # Cancel an in-flight server-initiated request
450
+ # @param request_id [String] The ID of the request to cancel
451
+ # @return [Symbol] cancellation outcome
452
+ # :cancelled, :already_cancelled, :already_completed, :not_found, :not_cancellable, :failed
453
+ def cancel_in_flight_request(request_id)
454
+ operation = nil
455
+ @in_flight_mutex.synchronize do
456
+ operation = @in_flight_requests[request_id.to_s]
457
+ end
458
+
459
+ unless operation
460
+ RubyLLM::MCP.logger.debug("Request #{request_id} was not found for cancellation")
461
+ return :not_found
462
+ end
463
+
464
+ unless operation.respond_to?(:cancel)
465
+ RubyLLM::MCP.logger.warn("Request #{request_id} cannot be cancelled or was already completed")
466
+ return :not_cancellable
467
+ end
468
+
469
+ outcome = normalize_cancellation_outcome(operation.cancel)
470
+ if %i[cancelled already_cancelled already_completed].include?(outcome)
471
+ unregister_in_flight_request(request_id)
472
+ end
473
+
474
+ outcome
475
+ end
476
+
477
+ private
478
+
479
+ def evaluate_tool_approval(name:, parameters:)
480
+ decision = @human_in_the_loop_callback.call(name, parameters)
481
+ unless decision.is_a?(Handlers::ApprovalDecision)
482
+ RubyLLM::MCP.logger.error(
483
+ "Human-in-the-loop callback must return ApprovalDecision, got #{decision.class}"
484
+ )
485
+ return false
486
+ end
487
+
488
+ return true if decision.approved?
489
+ return false if decision.denied?
490
+ return wait_for_deferred_approval(decision) if decision.deferred?
491
+
492
+ RubyLLM::MCP.logger.error(
493
+ "Human-in-the-loop callback returned unknown decision status '#{decision.status.inspect}'"
494
+ )
495
+ false
496
+ rescue Errors::InvalidApprovalDecision => e
497
+ RubyLLM::MCP.logger.error("Invalid approval decision: #{e.message}")
498
+ false
499
+ rescue StandardError => e
500
+ RubyLLM::MCP.logger.error("Error evaluating tool approval: #{e.message}")
501
+ false
502
+ end
503
+
504
+ def wait_for_deferred_approval(decision)
505
+ unless decision.promise
506
+ RubyLLM::MCP.logger.error("Deferred approval #{decision.approval_id} missing promise")
507
+ return false
508
+ end
509
+
510
+ approved = decision.promise.wait(timeout: decision.timeout)
511
+ approved == true
512
+ rescue Timeout::Error
513
+ RubyLLM::MCP.logger.warn(
514
+ "Deferred approval #{decision.approval_id} timed out after #{decision.timeout} seconds"
515
+ )
516
+ human_in_the_loop_registry.deny(
517
+ decision.approval_id,
518
+ reason: "Timed out waiting for approval"
519
+ )
520
+ false
521
+ rescue StandardError => e
522
+ RubyLLM::MCP.logger.error("Deferred approval #{decision.approval_id} failed: #{e.message}")
523
+ false
524
+ end
525
+
526
+ def normalize_cancellation_outcome(raw_outcome)
527
+ case raw_outcome
528
+ when Symbol
529
+ raw_outcome
530
+ when true
531
+ :cancelled
532
+ else
533
+ :failed
534
+ end
535
+ end
536
+
537
+ # Create a result for cancelled tool execution
538
+ def create_cancelled_result
539
+ Result.new(
540
+ {
541
+ "result" => {
542
+ "isError" => true,
543
+ "content" => [{ "type" => "text", "text" => TOOL_CALL_CANCELLED_MESSAGE }]
544
+ }
545
+ }
546
+ )
547
+ end
548
+ end
549
+ end
550
+ end
551
+ end