ruby_llm-mcp 0.0.2 → 0.2.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 +174 -9
- data/lib/ruby_llm/chat.rb +27 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/capabilities.rb +29 -0
- data/lib/ruby_llm/mcp/client.rb +85 -16
- data/lib/ruby_llm/mcp/completion.rb +15 -0
- data/lib/ruby_llm/mcp/content.rb +20 -0
- data/lib/ruby_llm/mcp/errors.rb +11 -1
- data/lib/ruby_llm/mcp/prompt.rb +95 -0
- data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +26 -10
- data/lib/ruby_llm/mcp/providers/open_ai/complex_parameter_support.rb +21 -10
- data/lib/ruby_llm/mcp/requests/completion.rb +50 -0
- data/lib/ruby_llm/mcp/requests/initialization.rb +3 -7
- data/lib/ruby_llm/mcp/requests/notification.rb +1 -1
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +23 -0
- data/lib/ruby_llm/mcp/requests/resource_list.rb +21 -0
- data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +21 -0
- data/lib/ruby_llm/mcp/resource.rb +99 -0
- data/lib/ruby_llm/mcp/tool.rb +21 -1
- data/lib/ruby_llm/mcp/transport/sse.rb +47 -15
- data/lib/ruby_llm/mcp/transport/stdio.rb +7 -7
- data/lib/ruby_llm/mcp/transport/streamable.rb +274 -4
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +4 -2
- metadata +18 -6
- data/lib/ruby_llm/overrides.rb +0 -21
@@ -1,20 +1,290 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "json"
|
4
|
+
require "uri"
|
5
|
+
require "faraday"
|
6
|
+
require "timeout"
|
7
|
+
require "securerandom"
|
8
|
+
|
3
9
|
module RubyLLM
|
4
10
|
module MCP
|
5
11
|
module Transport
|
6
12
|
class Streamable
|
13
|
+
attr_reader :headers, :id, :session_id
|
14
|
+
|
7
15
|
def initialize(url, headers: {})
|
8
16
|
@url = url
|
9
|
-
@
|
17
|
+
@client_id = SecureRandom.uuid
|
18
|
+
@session_id = nil
|
19
|
+
@base_headers = headers.merge({
|
20
|
+
"Content-Type" => "application/json",
|
21
|
+
"Accept" => "application/json, text/event-stream",
|
22
|
+
"Connection" => "keep-alive",
|
23
|
+
"X-CLIENT-ID" => @client_id
|
24
|
+
})
|
25
|
+
|
26
|
+
@id_counter = 0
|
27
|
+
@id_mutex = Mutex.new
|
28
|
+
@pending_requests = {}
|
29
|
+
@pending_mutex = Mutex.new
|
30
|
+
@running = true
|
31
|
+
@sse_streams = {}
|
32
|
+
@sse_mutex = Mutex.new
|
33
|
+
|
34
|
+
# Initialize HTTP connection
|
35
|
+
@connection = create_connection
|
10
36
|
end
|
11
37
|
|
12
|
-
def request(
|
13
|
-
#
|
38
|
+
def request(body, add_id: true, wait_for_response: true)
|
39
|
+
# Generate a unique request ID for requests
|
40
|
+
if add_id && body.is_a?(Hash) && !body.key?("id")
|
41
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
42
|
+
body["id"] = @id_counter
|
43
|
+
end
|
44
|
+
|
45
|
+
request_id = body.is_a?(Hash) ? body["id"] : nil
|
46
|
+
is_initialization = body.is_a?(Hash) && body["method"] == "initialize"
|
47
|
+
|
48
|
+
# Create a queue for this request's response if needed
|
49
|
+
response_queue = setup_response_queue(request_id, wait_for_response)
|
50
|
+
|
51
|
+
# Send the HTTP request
|
52
|
+
response = send_http_request(body, request_id, is_initialization: is_initialization)
|
53
|
+
|
54
|
+
# Handle different response types based on content
|
55
|
+
handle_response(response, request_id, response_queue, wait_for_response)
|
14
56
|
end
|
15
57
|
|
16
58
|
def close
|
17
|
-
|
59
|
+
@running = false
|
60
|
+
@sse_mutex.synchronize do
|
61
|
+
@sse_streams.each_value(&:close)
|
62
|
+
@sse_streams.clear
|
63
|
+
end
|
64
|
+
@connection&.close if @connection.respond_to?(:close)
|
65
|
+
@connection = nil
|
66
|
+
end
|
67
|
+
|
68
|
+
def terminate_session
|
69
|
+
return unless @session_id
|
70
|
+
|
71
|
+
begin
|
72
|
+
response = @connection.delete do |req|
|
73
|
+
build_headers.each { |key, value| req.headers[key] = value }
|
74
|
+
end
|
75
|
+
@session_id = nil if response.status == 200
|
76
|
+
rescue StandardError => e
|
77
|
+
# Server may not support session termination (405), which is allowed
|
78
|
+
puts "Warning: Failed to terminate session: #{e.message}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def create_connection
|
85
|
+
Faraday.new(url: @url) do |f|
|
86
|
+
f.options.timeout = 300
|
87
|
+
f.options.open_timeout = 10
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def build_headers
|
92
|
+
headers = @base_headers.dup
|
93
|
+
headers["Mcp-Session-Id"] = @session_id if @session_id
|
94
|
+
headers
|
95
|
+
end
|
96
|
+
|
97
|
+
def build_initialization_headers
|
98
|
+
@base_headers.dup
|
99
|
+
end
|
100
|
+
|
101
|
+
def setup_response_queue(request_id, wait_for_response)
|
102
|
+
response_queue = Queue.new
|
103
|
+
if wait_for_response && request_id
|
104
|
+
@pending_mutex.synchronize do
|
105
|
+
@pending_requests[request_id.to_s] = response_queue
|
106
|
+
end
|
107
|
+
end
|
108
|
+
response_queue
|
109
|
+
end
|
110
|
+
|
111
|
+
def send_http_request(body, request_id, is_initialization: false)
|
112
|
+
@connection.post do |req|
|
113
|
+
headers = is_initialization ? build_initialization_headers : build_headers
|
114
|
+
headers.each { |key, value| req.headers[key] = value }
|
115
|
+
req.body = JSON.generate(body)
|
116
|
+
end
|
117
|
+
rescue StandardError => e
|
118
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
119
|
+
raise e
|
120
|
+
end
|
121
|
+
|
122
|
+
def handle_response(response, request_id, response_queue, wait_for_response)
|
123
|
+
case response.status
|
124
|
+
when 200
|
125
|
+
handle_200_response(response, request_id, response_queue, wait_for_response)
|
126
|
+
when 202
|
127
|
+
# Accepted - for notifications/responses only, no body expected
|
128
|
+
nil
|
129
|
+
when 400..499
|
130
|
+
handle_client_error(response)
|
131
|
+
when 404
|
132
|
+
handle_session_expired
|
133
|
+
else
|
134
|
+
raise "HTTP request failed: #{response.status} - #{response.body}"
|
135
|
+
end
|
136
|
+
rescue StandardError => e
|
137
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
138
|
+
raise e
|
139
|
+
end
|
140
|
+
|
141
|
+
def handle_200_response(response, request_id, response_queue, wait_for_response)
|
142
|
+
content_type = response.headers["content-type"]
|
143
|
+
|
144
|
+
if content_type&.include?("text/event-stream")
|
145
|
+
handle_sse_response(response, request_id, response_queue, wait_for_response)
|
146
|
+
elsif content_type&.include?("application/json")
|
147
|
+
handle_json_response(response, request_id, response_queue, wait_for_response)
|
148
|
+
else
|
149
|
+
raise "Unexpected content type: #{content_type}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def handle_sse_response(response, request_id, response_queue, wait_for_response)
|
154
|
+
# Extract session ID from initial response if present
|
155
|
+
extract_session_id(response)
|
156
|
+
|
157
|
+
if wait_for_response && request_id
|
158
|
+
# Process SSE stream for this specific request
|
159
|
+
process_sse_for_request(response.body, request_id.to_s, response_queue)
|
160
|
+
# Wait for the response with timeout
|
161
|
+
wait_for_response_with_timeout(request_id.to_s, response_queue)
|
162
|
+
else
|
163
|
+
# Process general SSE stream
|
164
|
+
process_sse_stream(response.body)
|
165
|
+
nil
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def handle_json_response(response, request_id, response_queue, wait_for_response)
|
170
|
+
# Extract session ID from response if present
|
171
|
+
extract_session_id(response)
|
172
|
+
|
173
|
+
begin
|
174
|
+
json_response = JSON.parse(response.body)
|
175
|
+
|
176
|
+
if wait_for_response && request_id && response_queue
|
177
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
178
|
+
return json_response
|
179
|
+
end
|
180
|
+
|
181
|
+
json_response
|
182
|
+
rescue JSON::ParserError => e
|
183
|
+
raise "Invalid JSON response: #{e.message}"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def extract_session_id(response)
|
188
|
+
session_id = response.headers["Mcp-Session-Id"]
|
189
|
+
@session_id = session_id if session_id
|
190
|
+
end
|
191
|
+
|
192
|
+
def handle_client_error(response)
|
193
|
+
begin
|
194
|
+
error_body = JSON.parse(response.body)
|
195
|
+
if error_body.is_a?(Hash) && error_body["error"]
|
196
|
+
error_message = error_body["error"]["message"] || error_body["error"]["code"]
|
197
|
+
|
198
|
+
if error_message.to_s.downcase.include?("session")
|
199
|
+
raise "Server error: #{error_message} (Current session ID: #{@session_id || 'none'})"
|
200
|
+
end
|
201
|
+
|
202
|
+
raise "Server error: #{error_message}"
|
203
|
+
|
204
|
+
end
|
205
|
+
rescue JSON::ParserError
|
206
|
+
# Fall through to generic error
|
207
|
+
end
|
208
|
+
|
209
|
+
raise "HTTP client error: #{response.status} - #{response.body}"
|
210
|
+
end
|
211
|
+
|
212
|
+
def handle_session_expired
|
213
|
+
@session_id = nil
|
214
|
+
raise RubyLLM::MCP::Errors::SessionExpiredError.new(
|
215
|
+
message: "Session expired, re-initialization required"
|
216
|
+
)
|
217
|
+
end
|
218
|
+
|
219
|
+
def process_sse_for_request(sse_body, request_id, response_queue)
|
220
|
+
Thread.new do
|
221
|
+
process_sse_events(sse_body) do |event_data|
|
222
|
+
if event_data.is_a?(Hash) && event_data["id"]&.to_s == request_id
|
223
|
+
response_queue.push(event_data)
|
224
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id) }
|
225
|
+
break # Found our response, stop processing
|
226
|
+
end
|
227
|
+
end
|
228
|
+
rescue StandardError => e
|
229
|
+
puts "Error processing SSE stream: #{e.message}"
|
230
|
+
response_queue.push({ "error" => { "message" => e.message } })
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def process_sse_stream(sse_body)
|
235
|
+
Thread.new do
|
236
|
+
process_sse_events(sse_body) do |event_data|
|
237
|
+
# Handle server-initiated requests/notifications
|
238
|
+
handle_server_message(event_data) if event_data.is_a?(Hash)
|
239
|
+
end
|
240
|
+
rescue StandardError => e
|
241
|
+
puts "Error processing SSE stream: #{e.message}"
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def process_sse_events(sse_body)
|
246
|
+
event_buffer = ""
|
247
|
+
event_id = nil
|
248
|
+
|
249
|
+
sse_body.each_line do |line|
|
250
|
+
line = line.strip
|
251
|
+
|
252
|
+
if line.empty?
|
253
|
+
# End of event, process accumulated data
|
254
|
+
unless event_buffer.empty?
|
255
|
+
begin
|
256
|
+
event_data = JSON.parse(event_buffer)
|
257
|
+
yield event_data
|
258
|
+
rescue JSON::ParserError
|
259
|
+
puts "Warning: Failed to parse SSE event data: #{event_buffer}"
|
260
|
+
end
|
261
|
+
event_buffer = ""
|
262
|
+
end
|
263
|
+
elsif line.start_with?("id:")
|
264
|
+
event_id = line[3..].strip
|
265
|
+
elsif line.start_with?("data:")
|
266
|
+
data = line[5..].strip
|
267
|
+
event_buffer += data
|
268
|
+
elsif line.start_with?("event:")
|
269
|
+
# Event type - could be used for different message types
|
270
|
+
# For now, we treat all as data events
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
def handle_server_message(message)
|
276
|
+
# Handle server-initiated requests and notifications
|
277
|
+
# This would typically be passed to a message handler
|
278
|
+
puts "Received server message: #{message.inspect}"
|
279
|
+
end
|
280
|
+
|
281
|
+
def wait_for_response_with_timeout(request_id, response_queue)
|
282
|
+
Timeout.timeout(30) do
|
283
|
+
response_queue.pop
|
284
|
+
end
|
285
|
+
rescue Timeout::Error
|
286
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
287
|
+
raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
|
18
288
|
end
|
19
289
|
end
|
20
290
|
end
|
data/lib/ruby_llm/mcp/version.rb
CHANGED
data/lib/ruby_llm/mcp.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require "ruby_llm"
|
4
4
|
require "zeitwerk"
|
5
|
+
require_relative "chat"
|
5
6
|
|
6
7
|
loader = Zeitwerk::Loader.for_gem_extension(RubyLLM)
|
7
8
|
loader.inflector.inflect("mcp" => "MCP")
|
@@ -17,8 +18,9 @@ module RubyLLM
|
|
17
18
|
end
|
18
19
|
|
19
20
|
def support_complex_parameters!
|
20
|
-
require_relative "providers/open_ai/complex_parameter_support"
|
21
|
-
require_relative "providers/anthropic/complex_parameter_support"
|
21
|
+
require_relative "mcp/providers/open_ai/complex_parameter_support"
|
22
|
+
require_relative "mcp/providers/anthropic/complex_parameter_support"
|
23
|
+
require_relative "mcp/providers/gemini/complex_parameter_support"
|
22
24
|
end
|
23
25
|
end
|
24
26
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby_llm-mcp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Patrick Vice
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|
@@ -72,14 +72,14 @@ dependencies:
|
|
72
72
|
requirements:
|
73
73
|
- - "~>"
|
74
74
|
- !ruby/object:Gem::Version
|
75
|
-
version: '1.
|
75
|
+
version: '1.3'
|
76
76
|
type: :runtime
|
77
77
|
prerelease: false
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
79
79
|
requirements:
|
80
80
|
- - "~>"
|
81
81
|
- !ruby/object:Gem::Version
|
82
|
-
version: '1.
|
82
|
+
version: '1.3'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
84
|
name: zeitwerk
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -107,23 +107,35 @@ extra_rdoc_files: []
|
|
107
107
|
files:
|
108
108
|
- LICENSE
|
109
109
|
- README.md
|
110
|
+
- lib/ruby_llm/chat.rb
|
110
111
|
- lib/ruby_llm/mcp.rb
|
112
|
+
- lib/ruby_llm/mcp/attachment.rb
|
113
|
+
- lib/ruby_llm/mcp/capabilities.rb
|
111
114
|
- lib/ruby_llm/mcp/client.rb
|
115
|
+
- lib/ruby_llm/mcp/completion.rb
|
116
|
+
- lib/ruby_llm/mcp/content.rb
|
112
117
|
- lib/ruby_llm/mcp/errors.rb
|
113
118
|
- lib/ruby_llm/mcp/parameter.rb
|
119
|
+
- lib/ruby_llm/mcp/prompt.rb
|
114
120
|
- lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb
|
115
121
|
- lib/ruby_llm/mcp/providers/open_ai/complex_parameter_support.rb
|
116
122
|
- lib/ruby_llm/mcp/requests/base.rb
|
123
|
+
- lib/ruby_llm/mcp/requests/completion.rb
|
117
124
|
- lib/ruby_llm/mcp/requests/initialization.rb
|
118
125
|
- lib/ruby_llm/mcp/requests/notification.rb
|
126
|
+
- lib/ruby_llm/mcp/requests/prompt_call.rb
|
127
|
+
- lib/ruby_llm/mcp/requests/prompt_list.rb
|
128
|
+
- lib/ruby_llm/mcp/requests/resource_list.rb
|
129
|
+
- lib/ruby_llm/mcp/requests/resource_read.rb
|
130
|
+
- lib/ruby_llm/mcp/requests/resource_template_list.rb
|
119
131
|
- lib/ruby_llm/mcp/requests/tool_call.rb
|
120
132
|
- lib/ruby_llm/mcp/requests/tool_list.rb
|
133
|
+
- lib/ruby_llm/mcp/resource.rb
|
121
134
|
- lib/ruby_llm/mcp/tool.rb
|
122
135
|
- lib/ruby_llm/mcp/transport/sse.rb
|
123
136
|
- lib/ruby_llm/mcp/transport/stdio.rb
|
124
137
|
- lib/ruby_llm/mcp/transport/streamable.rb
|
125
138
|
- lib/ruby_llm/mcp/version.rb
|
126
|
-
- lib/ruby_llm/overrides.rb
|
127
139
|
homepage: https://github.com/patvice/ruby_llm-mcp
|
128
140
|
licenses:
|
129
141
|
- MIT
|
@@ -143,7 +155,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
143
155
|
requirements:
|
144
156
|
- - ">="
|
145
157
|
- !ruby/object:Gem::Version
|
146
|
-
version: 3.1.
|
158
|
+
version: 3.1.3
|
147
159
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
148
160
|
requirements:
|
149
161
|
- - ">="
|
data/lib/ruby_llm/overrides.rb
DELETED
@@ -1,21 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module RubyLLM
|
4
|
-
module ToolsComplexParametersSupport
|
5
|
-
def param_schema(param)
|
6
|
-
format = {
|
7
|
-
type: param.type,
|
8
|
-
description: param.description
|
9
|
-
}.compact
|
10
|
-
|
11
|
-
if param.type == "array"
|
12
|
-
format[:items] = param.items
|
13
|
-
elsif param.type == "object"
|
14
|
-
format[:properties] = param.properties
|
15
|
-
end
|
16
|
-
format
|
17
|
-
end
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
RubyLLM::Providers::OpenAI.extend(RubyLLM::ToolsComplexParametersSupport)
|