ruby_llm-mcp 0.5.0 → 0.6.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 +4 -4
- data/README.md +20 -620
- data/lib/generators/ruby_llm/mcp/install_generator.rb +27 -0
- data/lib/generators/ruby_llm/mcp/templates/README.txt +32 -0
- data/lib/generators/ruby_llm/mcp/templates/initializer.rb +42 -0
- data/lib/generators/ruby_llm/mcp/templates/mcps.yml +9 -0
- data/lib/ruby_llm/mcp/client.rb +56 -2
- data/lib/ruby_llm/mcp/completion.rb +3 -2
- data/lib/ruby_llm/mcp/configuration.rb +30 -1
- data/lib/ruby_llm/mcp/coordinator.rb +30 -6
- data/lib/ruby_llm/mcp/elicitation.rb +46 -0
- data/lib/ruby_llm/mcp/errors.rb +2 -0
- data/lib/ruby_llm/mcp/prompt.rb +4 -3
- data/lib/ruby_llm/mcp/protocol.rb +34 -0
- data/lib/ruby_llm/mcp/requests/completion_prompt.rb +13 -3
- data/lib/ruby_llm/mcp/requests/completion_resource.rb +13 -3
- data/lib/ruby_llm/mcp/resource.rb +1 -2
- data/lib/ruby_llm/mcp/resource_template.rb +4 -3
- data/lib/ruby_llm/mcp/response_handler.rb +10 -1
- data/lib/ruby_llm/mcp/responses/elicitation.rb +33 -0
- data/lib/ruby_llm/mcp/result.rb +2 -1
- data/lib/ruby_llm/mcp/tool.rb +33 -5
- data/lib/ruby_llm/mcp/transports/sse.rb +69 -25
- data/lib/ruby_llm/mcp/transports/stdio.rb +2 -2
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +87 -19
- data/lib/ruby_llm/mcp/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/transports/support/rate_limit.rb +47 -0
- data/lib/ruby_llm/mcp/transports/support/timeout.rb +34 -0
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +21 -9
- data/lib/tasks/release.rake +23 -0
- metadata +28 -8
- data/lib/ruby_llm/mcp/transports/http_client.rb +0 -26
- data/lib/ruby_llm/mcp/transports/timeout.rb +0 -32
data/lib/ruby_llm/mcp/tool.rb
CHANGED
@@ -36,6 +36,10 @@ module RubyLLM
|
|
36
36
|
@mcp_name = tool_response["name"]
|
37
37
|
@description = tool_response["description"].to_s
|
38
38
|
@parameters = create_parameters(tool_response["inputSchema"])
|
39
|
+
|
40
|
+
@input_schema = tool_response["inputSchema"]
|
41
|
+
@output_schema = tool_response["outputSchema"]
|
42
|
+
|
39
43
|
@annotations = tool_response["annotations"] ? Annotation.new(tool_response["annotations"]) : nil
|
40
44
|
end
|
41
45
|
|
@@ -59,6 +63,15 @@ module RubyLLM
|
|
59
63
|
return { error: "Tool execution error: #{text_values}" }
|
60
64
|
end
|
61
65
|
|
66
|
+
if result.value.key?("structuredContent") && !@output_schema.nil?
|
67
|
+
is_valid = JSON::Validator.validate(@output_schema, result.value["structuredContent"])
|
68
|
+
unless is_valid
|
69
|
+
return { error: "Structued outputs was not invalid: #{result.value['structuredContent']}" }
|
70
|
+
end
|
71
|
+
|
72
|
+
return text_values
|
73
|
+
end
|
74
|
+
|
62
75
|
if text_values.empty?
|
63
76
|
create_content_for_message(result.value.dig("content", 0))
|
64
77
|
else
|
@@ -79,12 +92,12 @@ module RubyLLM
|
|
79
92
|
|
80
93
|
private
|
81
94
|
|
82
|
-
def create_parameters(
|
95
|
+
def create_parameters(schema)
|
83
96
|
params = {}
|
84
|
-
return params if
|
97
|
+
return params if schema["properties"].nil?
|
85
98
|
|
86
|
-
|
87
|
-
param_data =
|
99
|
+
schema["properties"].each_key do |key|
|
100
|
+
param_data = schema.dig("properties", key)
|
88
101
|
|
89
102
|
param = if param_data.key?("oneOf") || param_data.key?("anyOf") || param_data.key?("allOf")
|
90
103
|
process_union_parameter(key, param_data)
|
@@ -152,10 +165,25 @@ module RubyLLM
|
|
152
165
|
"name" => name,
|
153
166
|
"description" => description,
|
154
167
|
"uri" => content.dig("resource", "uri"),
|
155
|
-
"
|
168
|
+
"mimeType" => content.dig("resource", "mimeType"),
|
169
|
+
"content_response" => {
|
170
|
+
"text" => content.dig("resource", "text"),
|
171
|
+
"blob" => content.dig("resource", "blob")
|
172
|
+
}
|
173
|
+
}
|
174
|
+
|
175
|
+
resource = Resource.new(coordinator, resource_data)
|
176
|
+
resource.to_content
|
177
|
+
when "resource_link"
|
178
|
+
resource_data = {
|
179
|
+
"name" => content["name"],
|
180
|
+
"uri" => content["uri"],
|
181
|
+
"description" => content["description"],
|
182
|
+
"mimeType" => content["mimeType"]
|
156
183
|
}
|
157
184
|
|
158
185
|
resource = Resource.new(coordinator, resource_data)
|
186
|
+
@coordinator.register_resource(resource)
|
159
187
|
resource.to_content
|
160
188
|
end
|
161
189
|
end
|
@@ -10,15 +10,16 @@ module RubyLLM
|
|
10
10
|
module MCP
|
11
11
|
module Transports
|
12
12
|
class SSE
|
13
|
-
include Timeout
|
13
|
+
include Support::Timeout
|
14
14
|
|
15
15
|
attr_reader :headers, :id, :coordinator
|
16
16
|
|
17
|
-
def initialize(url:, coordinator:, request_timeout:, headers: {})
|
17
|
+
def initialize(url:, coordinator:, request_timeout:, version: :http2, headers: {})
|
18
18
|
@event_url = url
|
19
19
|
@messages_url = nil
|
20
20
|
@coordinator = coordinator
|
21
21
|
@request_timeout = request_timeout
|
22
|
+
@version = version
|
22
23
|
|
23
24
|
uri = URI.parse(url)
|
24
25
|
@root_url = "#{uri.scheme}://#{uri.host}"
|
@@ -44,7 +45,7 @@ module RubyLLM
|
|
44
45
|
RubyLLM::MCP.logger.info "Initializing SSE transport to #{@event_url} with client ID #{@client_id}"
|
45
46
|
end
|
46
47
|
|
47
|
-
def request(body, add_id: true, wait_for_response: true)
|
48
|
+
def request(body, add_id: true, wait_for_response: true)
|
48
49
|
if add_id
|
49
50
|
@id_mutex.synchronize { @id_counter += 1 }
|
50
51
|
request_id = @id_counter
|
@@ -59,34 +60,20 @@ module RubyLLM
|
|
59
60
|
end
|
60
61
|
|
61
62
|
begin
|
62
|
-
|
63
|
-
|
64
|
-
response = http_client.post(@messages_url, body: JSON.generate(body))
|
65
|
-
|
66
|
-
unless response.status == 200
|
67
|
-
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
68
|
-
RubyLLM::MCP.logger.error "SSE request failed: #{response.status} - #{response.body}"
|
69
|
-
raise Errors::TransportError.new(
|
70
|
-
message: "Failed to request #{@messages_url}: #{response.status} - #{response.body}",
|
71
|
-
code: response.status
|
72
|
-
)
|
73
|
-
end
|
74
|
-
rescue StandardError => e
|
63
|
+
send_request(body, request_id)
|
64
|
+
rescue Errors::TransportError, Errors::TimeoutError => e
|
75
65
|
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
76
|
-
RubyLLM::MCP.logger.error "
|
77
|
-
raise
|
78
|
-
message: e.message,
|
79
|
-
code: -1,
|
80
|
-
error: e
|
81
|
-
)
|
66
|
+
RubyLLM::MCP.logger.error "Request error (ID: #{request_id}): #{e.message}"
|
67
|
+
raise e
|
82
68
|
end
|
69
|
+
|
83
70
|
return unless wait_for_response
|
84
71
|
|
85
72
|
begin
|
86
73
|
with_timeout(@request_timeout / 1000, request_id: request_id) do
|
87
74
|
response_queue.pop
|
88
75
|
end
|
89
|
-
rescue
|
76
|
+
rescue Errors::TimeoutError => e
|
90
77
|
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
91
78
|
RubyLLM::MCP.logger.error "SSE request timeout (ID: #{request_id}) after #{@request_timeout / 1000} seconds"
|
92
79
|
raise e
|
@@ -117,6 +104,23 @@ module RubyLLM
|
|
117
104
|
|
118
105
|
private
|
119
106
|
|
107
|
+
def send_request(body, request_id)
|
108
|
+
http_client = Support::HTTPClient.connection.with(timeout: { request_timeout: @request_timeout / 1000 },
|
109
|
+
headers: @headers)
|
110
|
+
response = http_client.post(@messages_url, body: JSON.generate(body))
|
111
|
+
handle_httpx_error_response!(response,
|
112
|
+
context: { location: "message endpoint request", request_id: request_id })
|
113
|
+
|
114
|
+
unless [200, 202].include?(response.status)
|
115
|
+
message = "Failed to have a successful request to #{@messages_url}: #{response.status} - #{response.body}"
|
116
|
+
RubyLLM::MCP.logger.error(message)
|
117
|
+
raise Errors::TransportError.new(
|
118
|
+
message: message,
|
119
|
+
code: response.status
|
120
|
+
)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
120
124
|
def start_sse_listener
|
121
125
|
@connection_mutex.synchronize do
|
122
126
|
return if sse_thread_running?
|
@@ -167,15 +171,36 @@ module RubyLLM
|
|
167
171
|
sse_client = sse_client.with(
|
168
172
|
headers: @headers
|
169
173
|
)
|
174
|
+
|
175
|
+
if @version == :http1
|
176
|
+
sse_client = sse_client.with(
|
177
|
+
ssl: { alpn_protocols: ["http/1.1"] }
|
178
|
+
)
|
179
|
+
end
|
180
|
+
|
170
181
|
response = sse_client.get(@event_url, stream: true)
|
182
|
+
|
183
|
+
event_buffer = []
|
171
184
|
response.each_line do |event_line|
|
172
185
|
unless @running
|
173
186
|
response.body.close
|
174
187
|
next
|
175
188
|
end
|
176
189
|
|
177
|
-
|
178
|
-
|
190
|
+
# Strip the line and check if it's empty (indicates end of event)
|
191
|
+
line = event_line.strip
|
192
|
+
|
193
|
+
if line.empty?
|
194
|
+
# End of event - process the accumulated buffer
|
195
|
+
if event_buffer.any?
|
196
|
+
event = parse_event(event_buffer.join("\n"))
|
197
|
+
process_event(event)
|
198
|
+
event_buffer.clear
|
199
|
+
end
|
200
|
+
else
|
201
|
+
# Accumulate the line for the current event
|
202
|
+
event_buffer << line
|
203
|
+
end
|
179
204
|
end
|
180
205
|
end
|
181
206
|
|
@@ -187,6 +212,25 @@ module RubyLLM
|
|
187
212
|
sleep 1
|
188
213
|
end
|
189
214
|
|
215
|
+
def handle_httpx_error_response!(response, context:)
|
216
|
+
return false unless response.is_a?(HTTPX::ErrorResponse)
|
217
|
+
|
218
|
+
error = response.error
|
219
|
+
|
220
|
+
if error.is_a?(HTTPX::ReadTimeoutError)
|
221
|
+
raise Errors::TimeoutError.new(
|
222
|
+
message: "Request timed out after #{@request_timeout / 1000} seconds"
|
223
|
+
)
|
224
|
+
end
|
225
|
+
|
226
|
+
error_message = response.error&.message || "Request failed"
|
227
|
+
|
228
|
+
raise Errors::TransportError.new(
|
229
|
+
code: nil,
|
230
|
+
message: "Request Error #{context}: #{error_message}"
|
231
|
+
)
|
232
|
+
end
|
233
|
+
|
190
234
|
def process_event(raw_event)
|
191
235
|
# Return if we believe that are getting a partial event
|
192
236
|
return if raw_event[:data].nil?
|
@@ -9,11 +9,11 @@ module RubyLLM
|
|
9
9
|
module MCP
|
10
10
|
module Transports
|
11
11
|
class Stdio
|
12
|
-
include Timeout
|
12
|
+
include Support::Timeout
|
13
13
|
|
14
14
|
attr_reader :command, :stdin, :stdout, :stderr, :id, :coordinator
|
15
15
|
|
16
|
-
def initialize(command:,
|
16
|
+
def initialize(command:, coordinator:, request_timeout:, args: [], env: {})
|
17
17
|
@request_timeout = request_timeout
|
18
18
|
@command = command
|
19
19
|
@coordinator = coordinator
|
@@ -27,6 +27,21 @@ module RubyLLM
|
|
27
27
|
end
|
28
28
|
end
|
29
29
|
|
30
|
+
class OAuthOptions
|
31
|
+
attr_reader :issuer, :client_id, :client_secret, :scope
|
32
|
+
|
33
|
+
def initialize(issuer:, client_id:, client_secret:, scopes:)
|
34
|
+
@issuer = issuer
|
35
|
+
@client_id = client_id
|
36
|
+
@client_secret = client_secret
|
37
|
+
@scope = scopes
|
38
|
+
end
|
39
|
+
|
40
|
+
def enabled?
|
41
|
+
@issuer && @client_id && @client_secret && @scope
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
30
45
|
# Options for starting SSE connections
|
31
46
|
class StartSSEOptions
|
32
47
|
attr_reader :resumption_token, :on_resumption_token, :replay_message_id
|
@@ -40,7 +55,7 @@ module RubyLLM
|
|
40
55
|
|
41
56
|
# Main StreamableHTTP transport class
|
42
57
|
class StreamableHTTP
|
43
|
-
include Timeout
|
58
|
+
include Support::Timeout
|
44
59
|
|
45
60
|
attr_reader :session_id, :protocol_version, :coordinator
|
46
61
|
|
@@ -49,6 +64,10 @@ module RubyLLM
|
|
49
64
|
request_timeout:,
|
50
65
|
coordinator:,
|
51
66
|
headers: {},
|
67
|
+
reconnection: {},
|
68
|
+
version: :http2,
|
69
|
+
oauth: nil,
|
70
|
+
rate_limit: nil,
|
52
71
|
reconnection_options: nil,
|
53
72
|
session_id: nil
|
54
73
|
)
|
@@ -57,11 +76,19 @@ module RubyLLM
|
|
57
76
|
@request_timeout = request_timeout
|
58
77
|
@headers = headers || {}
|
59
78
|
@session_id = session_id
|
79
|
+
|
80
|
+
@version = version
|
60
81
|
@reconnection_options = reconnection_options || ReconnectionOptions.new
|
61
82
|
@protocol_version = nil
|
83
|
+
@session_id = session_id
|
84
|
+
|
62
85
|
@resource_metadata_url = nil
|
63
86
|
@client_id = SecureRandom.uuid
|
64
87
|
|
88
|
+
@reconnection_options = ReconnectionOptions.new(**reconnection)
|
89
|
+
@oauth_options = OAuthOptions.new(**oauth) unless oauth.nil?
|
90
|
+
@rate_limiter = Support::RateLimiter.new(**rate_limit) if rate_limit
|
91
|
+
|
65
92
|
@id_counter = 0
|
66
93
|
@id_mutex = Mutex.new
|
67
94
|
@pending_requests = {}
|
@@ -79,6 +106,11 @@ module RubyLLM
|
|
79
106
|
end
|
80
107
|
|
81
108
|
def request(body, add_id: true, wait_for_response: true)
|
109
|
+
if @rate_limiter&.exceeded?
|
110
|
+
sleep(1) while @rate_limiter&.exceeded?
|
111
|
+
end
|
112
|
+
@rate_limiter&.add
|
113
|
+
|
82
114
|
# Generate a unique request ID for requests
|
83
115
|
if add_id && body.is_a?(Hash) && !body.key?("id")
|
84
116
|
@id_mutex.synchronize { @id_counter += 1 }
|
@@ -202,7 +234,7 @@ module RubyLLM
|
|
202
234
|
end
|
203
235
|
|
204
236
|
def create_connection
|
205
|
-
client = HTTPClient.connection.with(
|
237
|
+
client = Support::HTTPClient.connection.with(
|
206
238
|
timeout: {
|
207
239
|
connect_timeout: 10,
|
208
240
|
read_timeout: @request_timeout / 1000,
|
@@ -210,6 +242,18 @@ module RubyLLM
|
|
210
242
|
operation_timeout: @request_timeout / 1000
|
211
243
|
}
|
212
244
|
)
|
245
|
+
|
246
|
+
if @oauth_options&.enabled?
|
247
|
+
client = client.plugin(:oauth).oauth_auth(
|
248
|
+
issuer: @oauth_options.issuer,
|
249
|
+
client_id: @oauth_options.client_id,
|
250
|
+
client_secret: @oauth_options.client_secret,
|
251
|
+
scope: @oauth_options.scope
|
252
|
+
)
|
253
|
+
|
254
|
+
client.with_access_token
|
255
|
+
end
|
256
|
+
|
213
257
|
register_client(client)
|
214
258
|
end
|
215
259
|
|
@@ -219,6 +263,7 @@ module RubyLLM
|
|
219
263
|
headers["mcp-session-id"] = @session_id if @session_id
|
220
264
|
headers["mcp-protocol-version"] = @protocol_version if @protocol_version
|
221
265
|
headers["X-CLIENT-ID"] = @client_id
|
266
|
+
headers["Origin"] = @uri.to_s
|
222
267
|
|
223
268
|
headers
|
224
269
|
end
|
@@ -259,7 +304,8 @@ module RubyLLM
|
|
259
304
|
def create_connection_with_streaming_callbacks(request_id)
|
260
305
|
buffer = +""
|
261
306
|
|
262
|
-
client = HTTPClient.connection.plugin(:callbacks)
|
307
|
+
client = Support::HTTPClient.connection.plugin(:callbacks)
|
308
|
+
.on_response_body_chunk do |request, _response, chunk|
|
263
309
|
next unless @running && !@abort_controller
|
264
310
|
|
265
311
|
RubyLLM::MCP.logger.debug "Received chunk: #{chunk.bytesize} bytes for #{request.uri}"
|
@@ -274,6 +320,17 @@ module RubyLLM
|
|
274
320
|
operation_timeout: @request_timeout / 1000
|
275
321
|
}
|
276
322
|
)
|
323
|
+
|
324
|
+
if @oauth_options&.enabled?
|
325
|
+
client = client.plugin(:oauth).oauth_auth(
|
326
|
+
issuer: @oauth_options.issuer,
|
327
|
+
client_id: @oauth_options.client_id,
|
328
|
+
client_secret: @oauth_options.client_secret,
|
329
|
+
scope: @oauth_options.scope
|
330
|
+
)
|
331
|
+
|
332
|
+
client.with_access_token
|
333
|
+
end
|
277
334
|
register_client(client)
|
278
335
|
end
|
279
336
|
|
@@ -422,8 +479,8 @@ module RubyLLM
|
|
422
479
|
end
|
423
480
|
|
424
481
|
# Set up SSE streaming connection with callbacks
|
425
|
-
connection = create_connection_with_sse_callbacks(options)
|
426
|
-
response = connection.get(@url
|
482
|
+
connection = create_connection_with_sse_callbacks(options, headers)
|
483
|
+
response = connection.get(@url)
|
427
484
|
|
428
485
|
# Handle HTTPX error responses first
|
429
486
|
error_result = handle_httpx_error_response!(response, context: { location: "SSE connection" },
|
@@ -463,12 +520,32 @@ module RubyLLM
|
|
463
520
|
end
|
464
521
|
end
|
465
522
|
|
466
|
-
def create_connection_with_sse_callbacks(options)
|
467
|
-
|
523
|
+
def create_connection_with_sse_callbacks(options, headers)
|
524
|
+
client = HTTPX.plugin(:callbacks)
|
525
|
+
client = add_on_response_body_chunk_callback(client, options)
|
526
|
+
|
527
|
+
client = client.with(
|
528
|
+
timeout: {
|
529
|
+
connect_timeout: 10,
|
530
|
+
read_timeout: @request_timeout / 1000,
|
531
|
+
write_timeout: @request_timeout / 1000,
|
532
|
+
operation_timeout: @request_timeout / 1000
|
533
|
+
},
|
534
|
+
headers: headers
|
535
|
+
)
|
468
536
|
|
469
|
-
|
470
|
-
|
471
|
-
|
537
|
+
if @version == :http1
|
538
|
+
client = client.with(
|
539
|
+
ssl: { alpn_protocols: ["http/1.1"] }
|
540
|
+
)
|
541
|
+
end
|
542
|
+
|
543
|
+
register_client(client)
|
544
|
+
end
|
545
|
+
|
546
|
+
def add_on_response_body_chunk_callback(client, options)
|
547
|
+
buffer = +""
|
548
|
+
client.on_response_body_chunk do |request, response, chunk|
|
472
549
|
# Only process chunks for text/event-stream and if still running
|
473
550
|
next unless @running && !@abort_controller
|
474
551
|
|
@@ -495,15 +572,6 @@ module RubyLLM
|
|
495
572
|
end
|
496
573
|
end
|
497
574
|
end
|
498
|
-
.with(
|
499
|
-
timeout: {
|
500
|
-
connect_timeout: 10,
|
501
|
-
read_timeout: @request_timeout / 1000,
|
502
|
-
write_timeout: @request_timeout / 1000,
|
503
|
-
operation_timeout: @request_timeout / 1000
|
504
|
-
}
|
505
|
-
)
|
506
|
-
register_client(client)
|
507
575
|
end
|
508
576
|
|
509
577
|
def calculate_reconnection_delay(attempt)
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "httpx"
|
4
|
+
|
5
|
+
module RubyLLM
|
6
|
+
module MCP
|
7
|
+
module Transports
|
8
|
+
module Support
|
9
|
+
class HTTPClient
|
10
|
+
CONNECTION_KEY = :ruby_llm_mcp_client_connection
|
11
|
+
|
12
|
+
def self.connection
|
13
|
+
Thread.current[CONNECTION_KEY] ||= build_connection
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.build_connection
|
17
|
+
HTTPX.with(
|
18
|
+
pool_options: {
|
19
|
+
max_connections: RubyLLM::MCP.config.max_connections,
|
20
|
+
pool_timeout: RubyLLM::MCP.config.pool_timeout
|
21
|
+
}
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Transports
|
6
|
+
module Support
|
7
|
+
class RateLimit
|
8
|
+
def initialize(limit: 10, interval: 1000)
|
9
|
+
@limit = limit
|
10
|
+
@interval = interval
|
11
|
+
@timestamps = []
|
12
|
+
@mutex = Mutex.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def exceeded?
|
16
|
+
now = current_time
|
17
|
+
|
18
|
+
@mutex.synchronize do
|
19
|
+
purge_old(now)
|
20
|
+
@timestamps.size >= @limit
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def add
|
25
|
+
now = current_time
|
26
|
+
|
27
|
+
@mutex.synchronize do
|
28
|
+
purge_old(now)
|
29
|
+
@timestamps << now
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def current_time
|
36
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
37
|
+
end
|
38
|
+
|
39
|
+
def purge_old(now)
|
40
|
+
cutoff = now - @interval
|
41
|
+
@timestamps.reject! { |t| t < cutoff }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Transports
|
6
|
+
module Support
|
7
|
+
module Timeout
|
8
|
+
def with_timeout(seconds, request_id: nil)
|
9
|
+
result = nil
|
10
|
+
exception = nil
|
11
|
+
|
12
|
+
worker = Thread.new do
|
13
|
+
result = yield
|
14
|
+
rescue StandardError => e
|
15
|
+
exception = e
|
16
|
+
end
|
17
|
+
|
18
|
+
if worker.join(seconds)
|
19
|
+
raise exception if exception
|
20
|
+
|
21
|
+
result
|
22
|
+
else
|
23
|
+
worker.kill # stop the thread (can still have some risk if shared resources)
|
24
|
+
raise RubyLLM::MCP::Errors::TimeoutError.new(
|
25
|
+
message: "Request timed out after #{seconds} seconds",
|
26
|
+
request_id: request_id
|
27
|
+
)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/ruby_llm/mcp/version.rb
CHANGED
data/lib/ruby_llm/mcp.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "ruby_llm"
|
4
|
+
require "json-schema"
|
4
5
|
require "zeitwerk"
|
5
6
|
require_relative "chat"
|
6
7
|
|
@@ -9,20 +10,21 @@ module RubyLLM
|
|
9
10
|
module_function
|
10
11
|
|
11
12
|
def clients(config = RubyLLM::MCP.config.mcp_configuration)
|
12
|
-
@clients
|
13
|
-
|
14
|
-
|
13
|
+
if @clients.nil?
|
14
|
+
@clients = {}
|
15
|
+
config.map do |options|
|
16
|
+
@clients[options[:name]] ||= Client.new(**options)
|
17
|
+
end
|
15
18
|
end
|
19
|
+
@clients
|
16
20
|
end
|
17
21
|
|
18
22
|
def add_client(options)
|
19
|
-
|
20
|
-
@clients[options[:name]] ||= Client.new(**options)
|
23
|
+
clients[options[:name]] ||= Client.new(**options)
|
21
24
|
end
|
22
25
|
|
23
26
|
def remove_client(name)
|
24
|
-
|
25
|
-
client = @clients.delete(name)
|
27
|
+
client = clients.delete(name)
|
26
28
|
client&.stop
|
27
29
|
client
|
28
30
|
end
|
@@ -33,8 +35,18 @@ module RubyLLM
|
|
33
35
|
|
34
36
|
def establish_connection(&)
|
35
37
|
clients.each(&:start)
|
36
|
-
|
37
|
-
|
38
|
+
if block_given?
|
39
|
+
begin
|
40
|
+
yield clients
|
41
|
+
ensure
|
42
|
+
close_connection
|
43
|
+
end
|
44
|
+
else
|
45
|
+
clients
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def close_connection
|
38
50
|
clients.each do |client|
|
39
51
|
client.stop if client.alive?
|
40
52
|
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :release do
|
4
|
+
desc "Release a new version of the gem"
|
5
|
+
task :version do
|
6
|
+
# Load the current version from version.rb
|
7
|
+
require_relative "../../lib/ruby_llm/schema/version"
|
8
|
+
version = RubyLlm::Schema::VERSION
|
9
|
+
|
10
|
+
puts "Releasing version #{version}..."
|
11
|
+
|
12
|
+
# Make sure we are on the main branch
|
13
|
+
system "git checkout main"
|
14
|
+
system "git pull origin main"
|
15
|
+
|
16
|
+
# Create a new tag for the version
|
17
|
+
system "git tag -a v#{version} -m 'Release version #{version}'"
|
18
|
+
system "git push origin v#{version}"
|
19
|
+
|
20
|
+
system "gem build ruby_llm-mcp.gemspec"
|
21
|
+
system "gem push ruby_llm-mcp-#{version}.gem"
|
22
|
+
end
|
23
|
+
end
|