ruby_llm_swarm-mcp 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
- data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/chat.rb +34 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +401 -0
- data/lib/ruby_llm/mcp/completion.rb +16 -0
- data/lib/ruby_llm/mcp/configuration.rb +310 -0
- data/lib/ruby_llm/mcp/content.rb +28 -0
- data/lib/ruby_llm/mcp/elicitation.rb +48 -0
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +91 -0
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
- data/lib/ruby_llm/mcp/native/client.rb +387 -0
- data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
- data/lib/ruby_llm/mcp/native/messages.rb +36 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
- data/lib/ruby_llm/mcp/native.rb +12 -0
- data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
- data/lib/ruby_llm/mcp/progress.rb +35 -0
- data/lib/ruby_llm/mcp/prompt.rb +132 -0
- data/lib/ruby_llm/mcp/railtie.rb +14 -0
- data/lib/ruby_llm/mcp/resource.rb +112 -0
- data/lib/ruby_llm/mcp/resource_template.rb +85 -0
- data/lib/ruby_llm/mcp/result.rb +108 -0
- data/lib/ruby_llm/mcp/roots.rb +45 -0
- data/lib/ruby_llm/mcp/sample.rb +152 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
- data/lib/ruby_llm/mcp/tool.rb +228 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +125 -0
- data/lib/tasks/release.rake +23 -0
- metadata +184 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module MCP
|
|
7
|
+
class Client
|
|
8
|
+
extend Forwardable
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :config, :transport_type, :request_timeout, :log_level, :on, :roots, :adapter,
|
|
11
|
+
:on_logging_level
|
|
12
|
+
attr_accessor :linked_resources
|
|
13
|
+
|
|
14
|
+
def initialize(name:, transport_type:, sdk: nil, adapter: nil, start: true, # rubocop:disable Metrics/ParameterLists
|
|
15
|
+
request_timeout: MCP.config.request_timeout, config: {})
|
|
16
|
+
@name = name
|
|
17
|
+
@transport_type = transport_type.to_sym
|
|
18
|
+
@adapter_type = adapter || sdk || MCP.config.default_adapter
|
|
19
|
+
|
|
20
|
+
# Validate early
|
|
21
|
+
MCP.config.adapter_config.validate!(
|
|
22
|
+
adapter: @adapter_type,
|
|
23
|
+
transport: @transport_type
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@with_prefix = config.delete(:with_prefix) || false
|
|
27
|
+
@config = config.merge(request_timeout: request_timeout)
|
|
28
|
+
@request_timeout = request_timeout
|
|
29
|
+
|
|
30
|
+
# Store OAuth config for later use
|
|
31
|
+
@oauth_config = config[:oauth] || config["oauth"]
|
|
32
|
+
@oauth_provider = nil
|
|
33
|
+
@oauth_storage = nil
|
|
34
|
+
|
|
35
|
+
@on = {}
|
|
36
|
+
@tools = {}
|
|
37
|
+
@resources = {}
|
|
38
|
+
@resource_templates = {}
|
|
39
|
+
@prompts = {}
|
|
40
|
+
|
|
41
|
+
@log_level = nil
|
|
42
|
+
|
|
43
|
+
@linked_resources = []
|
|
44
|
+
|
|
45
|
+
# Build adapter based on configuration
|
|
46
|
+
@adapter = build_adapter
|
|
47
|
+
|
|
48
|
+
setup_roots if @adapter.supports?(:roots)
|
|
49
|
+
setup_sampling if @adapter.supports?(:sampling)
|
|
50
|
+
setup_event_handlers
|
|
51
|
+
|
|
52
|
+
@adapter.start if start
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def_delegators :@adapter, :alive?, :capabilities, :ping, :client_capabilities,
|
|
56
|
+
:register_in_flight_request, :unregister_in_flight_request,
|
|
57
|
+
:cancel_in_flight_request
|
|
58
|
+
|
|
59
|
+
def start
|
|
60
|
+
@adapter.start
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stop
|
|
64
|
+
@adapter.stop
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def restart!
|
|
68
|
+
@adapter.restart!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get or create OAuth provider for this client
|
|
72
|
+
# @param type [Symbol] OAuth provider type (:standard or :browser, defaults to :standard)
|
|
73
|
+
# @param options [Hash] additional options passed to provider
|
|
74
|
+
# @return [OAuthProvider, BrowserOAuthProvider] OAuth provider instance
|
|
75
|
+
def oauth(type: :standard, **options)
|
|
76
|
+
# Return existing provider if already created
|
|
77
|
+
return @oauth_provider if @oauth_provider
|
|
78
|
+
|
|
79
|
+
# Get provider from transport if it already exists
|
|
80
|
+
transport_oauth = transport_oauth_provider
|
|
81
|
+
return transport_oauth if transport_oauth
|
|
82
|
+
|
|
83
|
+
# Create new provider lazily
|
|
84
|
+
server_url = @config[:url] || @config["url"]
|
|
85
|
+
unless server_url
|
|
86
|
+
raise Errors::ConfigurationError.new(
|
|
87
|
+
message: "Cannot create OAuth provider without server URL in config"
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
oauth_options = {
|
|
92
|
+
server_url: server_url,
|
|
93
|
+
scope: @oauth_config&.dig(:scope) || @oauth_config&.dig("scope"),
|
|
94
|
+
storage: oauth_storage,
|
|
95
|
+
**options
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@oauth_provider = Auth.create_oauth(
|
|
99
|
+
server_url,
|
|
100
|
+
type: type,
|
|
101
|
+
**oauth_options
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def tools(refresh: false)
|
|
106
|
+
require_feature!(:tools)
|
|
107
|
+
return [] unless capabilities.tools_list?
|
|
108
|
+
|
|
109
|
+
fetch(:tools, refresh) do
|
|
110
|
+
tools = @adapter.tool_list
|
|
111
|
+
build_map(tools, MCP::Tool, with_prefix: @with_prefix)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@tools.values
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def tool(name, refresh: false)
|
|
118
|
+
tools(refresh: refresh)
|
|
119
|
+
|
|
120
|
+
@tools[name]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def reset_tools!
|
|
124
|
+
@tools = {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def resources(refresh: false)
|
|
128
|
+
require_feature!(:resources)
|
|
129
|
+
return [] unless capabilities.resources_list?
|
|
130
|
+
|
|
131
|
+
fetch(:resources, refresh) do
|
|
132
|
+
resources = @adapter.resource_list
|
|
133
|
+
resources = build_map(resources, MCP::Resource)
|
|
134
|
+
include_linked_resources(resources)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@resources.values
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resource(name, refresh: false)
|
|
141
|
+
resources(refresh: refresh)
|
|
142
|
+
|
|
143
|
+
@resources[name]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def reset_resources!
|
|
147
|
+
@resources = {}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resource_templates(refresh: false)
|
|
151
|
+
require_feature!(:resource_templates)
|
|
152
|
+
return [] unless capabilities.resources_list?
|
|
153
|
+
|
|
154
|
+
fetch(:resource_templates, refresh) do
|
|
155
|
+
resource_templates = @adapter.resource_template_list
|
|
156
|
+
build_map(resource_templates, MCP::ResourceTemplate)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@resource_templates.values
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def resource_template(name, refresh: false)
|
|
163
|
+
resource_templates(refresh: refresh)
|
|
164
|
+
|
|
165
|
+
@resource_templates[name]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def reset_resource_templates!
|
|
169
|
+
@resource_templates = {}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def prompts(refresh: false)
|
|
173
|
+
require_feature!(:prompts)
|
|
174
|
+
return [] unless capabilities.prompt_list?
|
|
175
|
+
|
|
176
|
+
fetch(:prompts, refresh) do
|
|
177
|
+
prompts = @adapter.prompt_list
|
|
178
|
+
build_map(prompts, MCP::Prompt)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
@prompts.values
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def prompt(name, refresh: false)
|
|
185
|
+
prompts(refresh: refresh)
|
|
186
|
+
|
|
187
|
+
@prompts[name]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def reset_prompts!
|
|
191
|
+
@prompts = {}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def tracking_progress?
|
|
195
|
+
@on.key?(:progress) && !@on[:progress].nil?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def on_progress(&block)
|
|
199
|
+
require_feature!(:progress_tracking)
|
|
200
|
+
if alive?
|
|
201
|
+
@adapter.set_progress_tracking(enabled: true)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
@on[:progress] = block
|
|
205
|
+
self
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def human_in_the_loop?
|
|
209
|
+
@on.key?(:human_in_the_loop) && !@on[:human_in_the_loop].nil?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def on_human_in_the_loop(&block)
|
|
213
|
+
require_feature!(:human_in_the_loop)
|
|
214
|
+
@on[:human_in_the_loop] = block
|
|
215
|
+
self
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def logging_handler_enabled?
|
|
219
|
+
@on.key?(:logging) && !@on[:logging].nil?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def logging_enabled?
|
|
223
|
+
!@log_level.nil?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def on_logging(level: Logging::WARNING, &block)
|
|
227
|
+
require_feature!(:logging)
|
|
228
|
+
@on_logging_level = level
|
|
229
|
+
if alive?
|
|
230
|
+
@adapter.set_logging(level: level)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
@on[:logging] = block
|
|
234
|
+
self
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def sampling_callback_enabled?
|
|
238
|
+
@on.key?(:sampling) && !@on[:sampling].nil?
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def on_sampling(&block)
|
|
242
|
+
require_feature!(:sampling)
|
|
243
|
+
@on[:sampling] = block
|
|
244
|
+
self
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def elicitation_enabled?
|
|
248
|
+
@on.key?(:elicitation) && !@on[:elicitation].nil?
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def on_elicitation(&block)
|
|
252
|
+
require_feature!(:elicitation)
|
|
253
|
+
@on[:elicitation] = block
|
|
254
|
+
self
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def to_h
|
|
258
|
+
{
|
|
259
|
+
name: @name,
|
|
260
|
+
transport_type: @transport_type,
|
|
261
|
+
request_timeout: @request_timeout,
|
|
262
|
+
start: @start,
|
|
263
|
+
config: @config,
|
|
264
|
+
on: @on,
|
|
265
|
+
tools: @tools,
|
|
266
|
+
resources: @resources,
|
|
267
|
+
resource_templates: @resource_templates,
|
|
268
|
+
prompts: @prompts,
|
|
269
|
+
log_level: @log_level
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
alias as_json to_h
|
|
274
|
+
|
|
275
|
+
def inspect
|
|
276
|
+
"#<#{self.class.name}:0x#{object_id.to_s(16)} #{to_h.map { |k, v| "#{k}: #{v}" }.join(', ')}>"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
# Get OAuth provider from adapter's transport if available
|
|
282
|
+
# @return [OAuthProvider, BrowserOAuthProvider, nil] OAuth provider or nil
|
|
283
|
+
def transport_oauth_provider
|
|
284
|
+
return nil unless @adapter
|
|
285
|
+
|
|
286
|
+
# For RubyLLMAdapter
|
|
287
|
+
if @adapter.respond_to?(:native_client)
|
|
288
|
+
transport = @adapter.native_client.transport
|
|
289
|
+
transport_protocol = transport.transport_protocol
|
|
290
|
+
return transport_protocol.oauth_provider if transport_protocol.respond_to?(:oauth_provider)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# For MCPSdkAdapter with wrapped transports
|
|
294
|
+
if @adapter.respond_to?(:mcp_client) && @adapter.instance_variable_get(:@mcp_client)
|
|
295
|
+
mcp_client = @adapter.instance_variable_get(:@mcp_client)
|
|
296
|
+
if mcp_client&.transport.respond_to?(:native_transport)
|
|
297
|
+
return mcp_client.transport.native_transport.oauth_provider
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def build_adapter
|
|
305
|
+
case @adapter_type
|
|
306
|
+
when :ruby_llm
|
|
307
|
+
RubyLLM::MCP::Adapters::RubyLLMAdapter.new(self,
|
|
308
|
+
transport_type: @transport_type,
|
|
309
|
+
config: @config)
|
|
310
|
+
when :mcp_sdk
|
|
311
|
+
RubyLLM::MCP::Adapters::MCPSdkAdapter.new(self,
|
|
312
|
+
transport_type: @transport_type,
|
|
313
|
+
config: @config)
|
|
314
|
+
else
|
|
315
|
+
raise ArgumentError, "Unknown adapter type: #{@adapter_type}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def require_feature!(feature)
|
|
320
|
+
unless @adapter.supports?(feature)
|
|
321
|
+
raise Errors::UnsupportedFeature.new(
|
|
322
|
+
message: <<~MSG.strip
|
|
323
|
+
Feature '#{feature}' is not supported by the #{@adapter_type} adapter.
|
|
324
|
+
|
|
325
|
+
This feature requires the :ruby_llm adapter.
|
|
326
|
+
Change your configuration to use adapter: :ruby_llm
|
|
327
|
+
MSG
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def fetch(cache_key, refresh)
|
|
333
|
+
instance_variable_set("@#{cache_key}", {}) if refresh
|
|
334
|
+
if instance_variable_get("@#{cache_key}").empty?
|
|
335
|
+
instance_variable_set("@#{cache_key}", yield)
|
|
336
|
+
end
|
|
337
|
+
instance_variable_get("@#{cache_key}")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def build_map(raw_data, klass, with_prefix: false)
|
|
341
|
+
raw_data.each_with_object({}) do |item, acc|
|
|
342
|
+
instance = if with_prefix
|
|
343
|
+
klass.new(@adapter, item, with_prefix: @with_prefix)
|
|
344
|
+
else
|
|
345
|
+
klass.new(@adapter, item)
|
|
346
|
+
end
|
|
347
|
+
acc[instance.name] = instance
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def include_linked_resources(resources)
|
|
352
|
+
@linked_resources.each do |resource|
|
|
353
|
+
resources[resource.name] = resource
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
resources
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def setup_roots
|
|
360
|
+
@roots = Roots.new(paths: MCP.config.roots, adapter: @adapter)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def setup_sampling
|
|
364
|
+
@on[:sampling] = MCP.config.sampling.guard
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def setup_event_handlers
|
|
368
|
+
# Only setup handlers that are supported
|
|
369
|
+
if @adapter.supports?(:progress_tracking)
|
|
370
|
+
@on[:progress] = MCP.config.on_progress
|
|
371
|
+
if @on[:progress] && alive?
|
|
372
|
+
@adapter.set_progress_tracking(enabled: true)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if @adapter.supports?(:human_in_the_loop)
|
|
377
|
+
@on[:human_in_the_loop] = MCP.config.on_human_in_the_loop
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if @adapter.supports?(:logging)
|
|
381
|
+
@on[:logging] = MCP.config.on_logging
|
|
382
|
+
@on_logging_level = MCP.config.on_logging_level
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
if @adapter.supports?(:elicitation)
|
|
386
|
+
@on[:elicitation] = MCP.config.on_elicitation
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Get or create OAuth storage shared with transport
|
|
391
|
+
def oauth_storage
|
|
392
|
+
# Try to get storage from transport's OAuth provider
|
|
393
|
+
transport_oauth = transport_oauth_provider
|
|
394
|
+
return transport_oauth.storage if transport_oauth
|
|
395
|
+
|
|
396
|
+
# Create new storage shared with client
|
|
397
|
+
@oauth_storage ||= Auth::MemoryStorage.new
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Completion
|
|
6
|
+
attr_reader :argument, :values, :total, :has_more
|
|
7
|
+
|
|
8
|
+
def initialize(argument:, values:, total:, has_more:)
|
|
9
|
+
@argument = argument
|
|
10
|
+
@values = values
|
|
11
|
+
@total = total
|
|
12
|
+
@has_more = has_more
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Configuration
|
|
6
|
+
class AdapterConfig
|
|
7
|
+
VALID_ADAPTERS = %i[ruby_llm mcp_sdk].freeze
|
|
8
|
+
VALID_TRANSPORTS = %i[stdio sse streamable streamable_http http].freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor :default_adapter
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@default_adapter = :ruby_llm
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate!(adapter:, transport:)
|
|
17
|
+
validate_adapter!(adapter)
|
|
18
|
+
validate_transport!(transport)
|
|
19
|
+
validate_adapter_transport_combination!(adapter, transport)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def adapter_for(config)
|
|
23
|
+
config[:sdk] || config[:adapter] || @default_adapter
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def validate_adapter!(adapter)
|
|
29
|
+
unless VALID_ADAPTERS.include?(adapter)
|
|
30
|
+
raise Errors::AdapterConfigurationError.new(
|
|
31
|
+
message: "Invalid adapter '#{adapter}'. Valid options: #{VALID_ADAPTERS.join(', ')}"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_transport!(transport)
|
|
37
|
+
unless VALID_TRANSPORTS.include?(transport)
|
|
38
|
+
raise Errors::AdapterConfigurationError.new(
|
|
39
|
+
message: "Invalid transport '#{transport}'. Valid options: #{VALID_TRANSPORTS.join(', ')}"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_adapter_transport_combination!(adapter, transport)
|
|
45
|
+
# SSE is supported by both ruby_llm and mcp_sdk adapters
|
|
46
|
+
# No validation needed at this time
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Sampling
|
|
51
|
+
attr_accessor :enabled
|
|
52
|
+
attr_writer :preferred_model
|
|
53
|
+
|
|
54
|
+
def initialize
|
|
55
|
+
set_defaults
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reset!
|
|
59
|
+
set_defaults
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def guard(&block)
|
|
63
|
+
@guard = block if block_given?
|
|
64
|
+
@guard
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def preferred_model(&block)
|
|
68
|
+
@preferred_model = block if block_given?
|
|
69
|
+
@preferred_model
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def enabled?
|
|
73
|
+
@enabled
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def set_defaults
|
|
79
|
+
@enabled = false
|
|
80
|
+
@preferred_model = nil
|
|
81
|
+
@guard = nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class OAuth
|
|
86
|
+
attr_accessor :client_name,
|
|
87
|
+
:client_uri,
|
|
88
|
+
:software_id,
|
|
89
|
+
:software_version,
|
|
90
|
+
:logo_uri,
|
|
91
|
+
:contacts,
|
|
92
|
+
:tos_uri,
|
|
93
|
+
:policy_uri,
|
|
94
|
+
:jwks_uri,
|
|
95
|
+
:jwks,
|
|
96
|
+
:browser_success_page,
|
|
97
|
+
:browser_error_page
|
|
98
|
+
|
|
99
|
+
def initialize
|
|
100
|
+
@client_name = "RubyLLM MCP Client"
|
|
101
|
+
@client_uri = nil
|
|
102
|
+
@software_id = "ruby_llm-mcp"
|
|
103
|
+
@software_version = RubyLLM::MCP::VERSION
|
|
104
|
+
@logo_uri = nil
|
|
105
|
+
@contacts = nil
|
|
106
|
+
@tos_uri = nil
|
|
107
|
+
@policy_uri = nil
|
|
108
|
+
@jwks_uri = nil
|
|
109
|
+
@jwks = nil
|
|
110
|
+
@browser_success_page = nil
|
|
111
|
+
@browser_error_page = nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class ConfigFile
|
|
116
|
+
attr_reader :file_path
|
|
117
|
+
|
|
118
|
+
def initialize(file_path)
|
|
119
|
+
@file_path = file_path
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse
|
|
123
|
+
@parse ||= if @file_path && File.exist?(@file_path)
|
|
124
|
+
config = parse_config_file
|
|
125
|
+
load_mcps_config(config)
|
|
126
|
+
else
|
|
127
|
+
[]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def parse_config_file
|
|
134
|
+
output = ERB.new(File.read(@file_path)).result
|
|
135
|
+
|
|
136
|
+
if [".yaml", ".yml"].include?(File.extname(@file_path))
|
|
137
|
+
YAML.safe_load(output, symbolize_names: true)
|
|
138
|
+
else
|
|
139
|
+
JSON.parse(output, symbolize_names: true)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def load_mcps_config(config)
|
|
144
|
+
return [] unless config.key?(:mcp_servers)
|
|
145
|
+
|
|
146
|
+
config[:mcp_servers].map do |name, configuration|
|
|
147
|
+
{
|
|
148
|
+
name: name,
|
|
149
|
+
transport_type: configuration.delete(:transport_type),
|
|
150
|
+
start: false,
|
|
151
|
+
config: configuration
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
attr_accessor :request_timeout,
|
|
158
|
+
:log_file,
|
|
159
|
+
:log_level,
|
|
160
|
+
:roots,
|
|
161
|
+
:sampling,
|
|
162
|
+
:max_connections,
|
|
163
|
+
:pool_timeout,
|
|
164
|
+
:protocol_version,
|
|
165
|
+
:config_path,
|
|
166
|
+
:launch_control,
|
|
167
|
+
:on_logging_level,
|
|
168
|
+
:adapter_config,
|
|
169
|
+
:oauth
|
|
170
|
+
|
|
171
|
+
attr_writer :logger, :mcp_configuration
|
|
172
|
+
|
|
173
|
+
REQUEST_TIMEOUT_DEFAULT = 8000
|
|
174
|
+
|
|
175
|
+
def initialize
|
|
176
|
+
@sampling = Sampling.new
|
|
177
|
+
@adapter_config = AdapterConfig.new
|
|
178
|
+
@oauth = OAuth.new
|
|
179
|
+
set_defaults
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def reset!
|
|
183
|
+
set_defaults
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def logger
|
|
187
|
+
@logger ||= Logger.new(
|
|
188
|
+
log_file,
|
|
189
|
+
progname: "RubyLLM::MCP",
|
|
190
|
+
level: log_level
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Convenience method for setting default adapter
|
|
195
|
+
def default_adapter=(adapter)
|
|
196
|
+
@adapter_config.default_adapter = adapter
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def default_adapter
|
|
200
|
+
@adapter_config.default_adapter
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Validate MCP configuration before use
|
|
204
|
+
def mcp_configuration
|
|
205
|
+
configs = @mcp_configuration + load_mcps_config
|
|
206
|
+
validate_configurations!(configs)
|
|
207
|
+
configs
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def on_progress(&block)
|
|
211
|
+
@on_progress = block if block_given?
|
|
212
|
+
@on_progress
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def on_human_in_the_loop(&block)
|
|
216
|
+
@on_human_in_the_loop = block if block_given?
|
|
217
|
+
@on_human_in_the_loop
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def on_logging(&block)
|
|
221
|
+
@on_logging = block if block_given?
|
|
222
|
+
@on_logging
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def on_elicitation(&block)
|
|
226
|
+
@on_elicitation = block if block_given?
|
|
227
|
+
@on_elicitation
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def inspect
|
|
231
|
+
redacted = lambda do |name, value|
|
|
232
|
+
if name.match?(/_id|_key|_secret|_token$/)
|
|
233
|
+
value.nil? ? "nil" : "[FILTERED]"
|
|
234
|
+
else
|
|
235
|
+
value
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
inspection = instance_variables.map do |ivar|
|
|
240
|
+
name = ivar.to_s.delete_prefix("@")
|
|
241
|
+
value = redacted[name, instance_variable_get(ivar)]
|
|
242
|
+
"#{name}: #{value}"
|
|
243
|
+
end.join(", ")
|
|
244
|
+
|
|
245
|
+
"#<#{self.class}:0x#{object_id.to_s(16)} #{inspection}>"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def validate_configurations!(configs)
|
|
251
|
+
configs.each do |config|
|
|
252
|
+
adapter = @adapter_config.adapter_for(config)
|
|
253
|
+
transport = config[:transport_type]
|
|
254
|
+
# Convert string to symbol if needed
|
|
255
|
+
transport = transport.to_sym if transport.is_a?(String)
|
|
256
|
+
|
|
257
|
+
@adapter_config.validate!(
|
|
258
|
+
adapter: adapter,
|
|
259
|
+
transport: transport
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def load_mcps_config
|
|
265
|
+
@config_file ||= ConfigFile.new(config_path)
|
|
266
|
+
@config_file.parse
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def set_defaults
|
|
270
|
+
# Connection configuration
|
|
271
|
+
@request_timeout = REQUEST_TIMEOUT_DEFAULT
|
|
272
|
+
|
|
273
|
+
# Connection Pool
|
|
274
|
+
@max_connections = Float::INFINITY
|
|
275
|
+
@pool_timeout = 5
|
|
276
|
+
|
|
277
|
+
# Logging configuration
|
|
278
|
+
@log_file = $stdout
|
|
279
|
+
@log_level = ENV["RUBYLLM_MCP_DEBUG"] ? Logger::DEBUG : Logger::INFO
|
|
280
|
+
@logger = nil
|
|
281
|
+
|
|
282
|
+
# MCPs configuration
|
|
283
|
+
@mcps_config_path = nil
|
|
284
|
+
@mcp_configuration = []
|
|
285
|
+
|
|
286
|
+
# Rails specific configuration
|
|
287
|
+
@launch_control = :automatic
|
|
288
|
+
|
|
289
|
+
# Roots configuration
|
|
290
|
+
@roots = []
|
|
291
|
+
|
|
292
|
+
# Protocol configuration
|
|
293
|
+
@protocol_version = Native::Protocol.latest_version
|
|
294
|
+
|
|
295
|
+
# OAuth configuration
|
|
296
|
+
@oauth = OAuth.new
|
|
297
|
+
|
|
298
|
+
# Sampling configuration
|
|
299
|
+
@sampling.reset!
|
|
300
|
+
|
|
301
|
+
# Event handlers
|
|
302
|
+
@on_progress = nil
|
|
303
|
+
@on_human_in_the_loop = nil
|
|
304
|
+
@on_elicitation = nil
|
|
305
|
+
@on_logging_level = nil
|
|
306
|
+
@on_logging = nil
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|