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.
@@ -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
- @headers = headers
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(messages)
13
- # TODO: Implement streaming
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
- # TODO: Implement closing
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module MCP
5
- VERSION = "0.0.2"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
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.2
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-05-24 00:00:00.000000000 Z
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.2'
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.2'
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.0
158
+ version: 3.1.3
147
159
  required_rubygems_version: !ruby/object:Gem::Requirement
148
160
  requirements:
149
161
  - - ">="
@@ -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)