ruby_llm-mcp 0.2.1 → 0.3.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.
@@ -10,21 +10,35 @@ module RubyLLM
10
10
  def param_schema(param)
11
11
  properties = case param.type
12
12
  when :array
13
- {
14
- type: param.type,
15
- items: { type: param.item_type }
16
- }
13
+ if param.item_type == :object
14
+ {
15
+ type: param.type,
16
+ items: {
17
+ type: param.item_type,
18
+ properties: param.properties.transform_values { |value| param_schema(value) }
19
+ }
20
+ }
21
+ else
22
+ {
23
+ type: param.type,
24
+ items: { type: param.item_type, enum: param.enum }.compact
25
+ }
26
+ end
17
27
  when :object
18
28
  {
19
29
  type: param.type,
20
30
  properties: param.properties.transform_values { |value| param_schema(value) },
21
31
  required: param.properties.select { |_, p| p.required }.keys
22
32
  }
33
+ when :union
34
+ {
35
+ param.union_type => param.properties.map { |property| param_schema(property) }
36
+ }
23
37
  else
24
38
  {
25
39
  type: param.type,
26
40
  description: param.description
27
- }
41
+ }.compact
28
42
  end
29
43
 
30
44
  properties.compact
@@ -3,10 +3,9 @@
3
3
  module RubyLLM
4
4
  module MCP
5
5
  module Requests
6
- class Completion
7
- def initialize(client, type:, name:, argument:, value:)
6
+ class CompletionPrompt
7
+ def initialize(client, name:, argument:, value:)
8
8
  @client = client
9
- @type = type
10
9
  @name = name
11
10
  @argument = argument
12
11
  @value = value
@@ -25,7 +24,7 @@ module RubyLLM
25
24
  method: "completion/complete",
26
25
  params: {
27
26
  ref: {
28
- type: ref_type,
27
+ type: "ref/prompt",
29
28
  name: @name
30
29
  },
31
30
  argument: {
@@ -35,15 +34,6 @@ module RubyLLM
35
34
  }
36
35
  }
37
36
  end
38
-
39
- def ref_type
40
- case @type
41
- when :prompt
42
- "ref/prompt"
43
- when :resource
44
- "ref/resource"
45
- end
46
- end
47
37
  end
48
38
  end
49
39
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class CompletionResource
7
+ def initialize(client, uri:, argument:, value:)
8
+ @client = client
9
+ @uri = uri
10
+ @argument = argument
11
+ @value = value
12
+ end
13
+
14
+ def call
15
+ @client.request(request_body)
16
+ end
17
+
18
+ private
19
+
20
+ def request_body
21
+ {
22
+ jsonrpc: "2.0",
23
+ id: 1,
24
+ method: "completion/complete",
25
+ params: {
26
+ ref: {
27
+ type: "ref/resource",
28
+ uri: @uri
29
+ },
30
+ argument: {
31
+ name: @argument,
32
+ value: @value
33
+ }
34
+ }
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,37 +3,26 @@
3
3
  module RubyLLM
4
4
  module MCP
5
5
  class Resource
6
- attr_reader :uri, :name, :description, :mime_type, :mcp_client, :template
6
+ attr_reader :uri, :name, :description, :mime_type, :mcp_client
7
7
 
8
- def initialize(mcp_client, resource, template: false)
8
+ def initialize(mcp_client, resource)
9
9
  @mcp_client = mcp_client
10
10
  @uri = resource["uri"]
11
11
  @name = resource["name"]
12
12
  @description = resource["description"]
13
13
  @mime_type = resource["mimeType"]
14
- @content = resource["content"] || nil
15
- @template = template
14
+ if resource.key?("content_response")
15
+ @content_response = resource["content_response"]
16
+ @content = @content_response["text"] || @content_response["blob"]
17
+ end
16
18
  end
17
19
 
18
- def content(arguments: {})
19
- return @content if @content && !template?
20
-
21
- response = if template?
22
- templated_uri = apply_template(@uri, arguments)
23
-
24
- read_response(uri: templated_uri)
25
- else
26
- read_response
27
- end
28
-
29
- content = response.dig("result", "contents", 0)
30
- @type = if content.key?("type")
31
- content["type"]
32
- else
33
- "text"
34
- end
20
+ def content
21
+ return @content unless @content.nil?
35
22
 
36
- @content = content["text"] || content["blob"]
23
+ response = read_response
24
+ @content_response = response.dig("result", "contents", 0)
25
+ @content = @content_response["text"] || @content_response["blob"]
37
26
  end
38
27
 
39
28
  def include(chat, **args)
@@ -45,35 +34,30 @@ module RubyLLM
45
34
  chat.add_message(message)
46
35
  end
47
36
 
48
- def to_content(arguments: {})
49
- content = content(arguments: arguments)
37
+ def to_content
38
+ content = self.content
50
39
 
51
- case @type
40
+ case content_type
52
41
  when "text"
53
- MCP::Content.new(content)
42
+ MCP::Content.new(text: "#{name}: #{description}\n\n#{content}")
54
43
  when "blob"
55
44
  attachment = MCP::Attachment.new(content, mime_type)
56
45
  MCP::Content.new(text: "#{name}: #{description}", attachments: [attachment])
57
46
  end
58
47
  end
59
48
 
60
- def arguments_search(argument, value)
61
- if template? && @mcp_client.capabilities.completion?
62
- response = @mcp_client.completion(type: :resource, name: @name, argument: argument, value: value)
63
- response = response.dig("result", "completion")
49
+ private
50
+
51
+ def content_type
52
+ return "text" if @content_response.nil?
64
53
 
65
- Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
54
+ if @content_response.key?("blob")
55
+ "blob"
66
56
  else
67
- raise Errors::CompletionNotAvailable, "Completion is not available for this MCP server"
57
+ "text"
68
58
  end
69
59
  end
70
60
 
71
- def template?
72
- @template
73
- end
74
-
75
- private
76
-
77
61
  def read_response(uri: @uri)
78
62
  parsed = URI.parse(uri)
79
63
  case parsed.scheme
@@ -88,12 +72,6 @@ module RubyLLM
88
72
  response = Faraday.get(uri)
89
73
  { "result" => { "contents" => [{ "text" => response.body }] } }
90
74
  end
91
-
92
- def apply_template(uri, arguments)
93
- uri.gsub(/\{(\w+)\}/) do
94
- arguments[::Regexp.last_match(1).to_sym] || "{#{::Regexp.last_match(1)}}"
95
- end
96
- end
97
75
  end
98
76
  end
99
77
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class ResourceTemplate
6
+ attr_reader :uri, :name, :description, :mime_type, :mcp_client, :template
7
+
8
+ def initialize(mcp_client, resource)
9
+ @mcp_client = mcp_client
10
+ @uri = resource["uriTemplate"]
11
+ @name = resource["name"]
12
+ @description = resource["description"]
13
+ @mime_type = resource["mimeType"]
14
+ end
15
+
16
+ def fetch_resource(arguments: {})
17
+ uri = apply_template(@uri, arguments)
18
+ response = read_response(uri)
19
+ content_response = response.dig("result", "contents", 0)
20
+
21
+ Resource.new(mcp_client, {
22
+ "uri" => uri,
23
+ "name" => "#{@name} (#{uri})",
24
+ "description" => @description,
25
+ "mimeType" => @mime_type,
26
+ "content_response" => content_response
27
+ })
28
+ end
29
+
30
+ def to_content(arguments: {})
31
+ fetch_resource(arguments: arguments).to_content
32
+ end
33
+
34
+ def complete(argument, value)
35
+ if @mcp_client.capabilities.completion?
36
+ response = @mcp_client.completion_resource(uri: @uri, argument: argument, value: value)
37
+ response = response.dig("result", "completion")
38
+
39
+ Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
40
+ else
41
+ raise Errors::CompletionNotAvailable.new(message: "Completion is not available for this MCP server")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def content_type
48
+ if @content.key?("type")
49
+ @content["type"]
50
+ else
51
+ "text"
52
+ end
53
+ end
54
+
55
+ def read_response(uri)
56
+ parsed = URI.parse(uri)
57
+ case parsed.scheme
58
+ when "http", "https"
59
+ fetch_uri_content(uri)
60
+ else # file:// or git://
61
+ @mcp_client.resource_read_request(uri: uri)
62
+ end
63
+ end
64
+
65
+ def fetch_uri_content(uri)
66
+ response = Faraday.get(uri)
67
+ { "result" => { "contents" => [{ "text" => response.body }] } }
68
+ end
69
+
70
+ def apply_template(uri, arguments)
71
+ uri.gsub(/\{(\w+)\}/) do
72
+ arguments[::Regexp.last_match(1).to_s] ||
73
+ arguments[::Regexp.last_match(1).to_sym] ||
74
+ "{#{::Regexp.last_match(1)}}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -10,7 +10,7 @@ module RubyLLM
10
10
  @mcp_client = mcp_client
11
11
 
12
12
  @name = tool_response["name"]
13
- @description = tool_response["description"]
13
+ @description = tool_response["description"].to_s
14
14
  @parameters = create_parameters(tool_response["inputSchema"])
15
15
  end
16
16
 
@@ -21,10 +21,11 @@ module RubyLLM
21
21
  )
22
22
 
23
23
  text_values = response.dig("result", "content").map { |content| content["text"] }.compact.join("\n")
24
+
24
25
  if text_values.empty?
25
26
  create_content_for_message(response.dig("result", "content", 0))
26
27
  else
27
- create_content_for_message({ type: "text", text: text_values })
28
+ create_content_for_message({ "type" => "text", "text" => text_values })
28
29
  end
29
30
  end
30
31
 
@@ -32,20 +33,16 @@ module RubyLLM
32
33
 
33
34
  def create_parameters(input_schema)
34
35
  params = {}
36
+ return params if input_schema["properties"].nil?
37
+
35
38
  input_schema["properties"].each_key do |key|
36
- param = RubyLLM::MCP::Parameter.new(
37
- key,
38
- type: input_schema["properties"][key]["type"],
39
- desc: input_schema["properties"][key]["description"],
40
- required: input_schema["properties"][key]["required"]
41
- )
42
-
43
- if param.type == "array"
44
- param.items = input_schema["properties"][key]["items"]
45
- elsif param.type == "object"
46
- properties = create_parameters(input_schema["properties"][key]["properties"])
47
- param.properties = properties
48
- end
39
+ param_data = input_schema.dig("properties", key)
40
+
41
+ param = if param_data.key?("oneOf") || param_data.key?("anyOf") || param_data.key?("allOf")
42
+ process_union_parameter(key, param_data)
43
+ else
44
+ process_parameter(key, param_data)
45
+ end
49
46
 
50
47
  params[key] = param
51
48
  end
@@ -53,15 +50,63 @@ module RubyLLM
53
50
  params
54
51
  end
55
52
 
53
+ def process_union_parameter(key, param_data)
54
+ union_type = param_data.keys.first
55
+ param = RubyLLM::MCP::Parameter.new(
56
+ key,
57
+ type: :union,
58
+ union_type: union_type
59
+ )
60
+
61
+ param.properties = param_data[union_type].map do |value|
62
+ process_parameter(key, value, lifted_type: param_data["type"])
63
+ end.compact
64
+
65
+ param
66
+ end
67
+
68
+ def process_parameter(key, param_data, lifted_type: nil)
69
+ param = RubyLLM::MCP::Parameter.new(
70
+ key,
71
+ type: param_data["type"] || lifted_type,
72
+ desc: param_data["description"],
73
+ required: param_data["required"]
74
+ )
75
+
76
+ if param.type == :array
77
+ items = param_data["items"]
78
+ param.items = items
79
+ if items.key?("properties")
80
+ param.properties = create_parameters(items)
81
+ end
82
+ if param_data.key?("enum")
83
+ param.enum = param_data["enum"]
84
+ end
85
+ elsif param.type == :object
86
+ if param_data.key?("properties")
87
+ param.properties = create_parameters(param_data)
88
+ end
89
+ end
90
+
91
+ param
92
+ end
93
+
56
94
  def create_content_for_message(content)
57
95
  case content["type"]
58
96
  when "text"
59
97
  MCP::Content.new(text: content["text"])
60
98
  when "image", "audio"
61
- attachment = MCP::Attachment.new(content["content"], content["mime_type"])
99
+ attachment = MCP::Attachment.new(content["data"], content["mimeType"])
62
100
  MCP::Content.new(text: nil, attachments: [attachment])
63
101
  when "resource"
64
- resource = Resource.new(mcp_client, content["resource"])
102
+ resource_data = {
103
+ "name" => name,
104
+ "description" => description,
105
+ "uri" => content.dig("resource", "uri"),
106
+ "content" => content["resource"]
107
+ }
108
+
109
+ resource = Resource.new(mcp_client, resource_data)
65
110
  resource.to_content
66
111
  end
67
112
  end
@@ -40,8 +40,7 @@ module RubyLLM
40
40
  start_sse_listener
41
41
  end
42
42
 
43
- # rubocop:disable Metrics/MethodLength
44
- def request(body, add_id: true, wait_for_response: true)
43
+ def request(body, add_id: true, wait_for_response: true) # rubocop:disable Metrics/MethodLength
45
44
  # Generate a unique request ID
46
45
  if add_id
47
46
  @id_mutex.synchronize { @id_counter += 1 }
@@ -60,7 +59,7 @@ module RubyLLM
60
59
  # Send the request using Faraday
61
60
  begin
62
61
  conn = Faraday.new do |f|
63
- f.options.timeout = 30
62
+ f.options.timeout = @request_timeout / 1000
64
63
  f.options.open_timeout = 5
65
64
  end
66
65
 
@@ -83,15 +82,20 @@ module RubyLLM
83
82
  return unless wait_for_response
84
83
 
85
84
  begin
86
- Timeout.timeout(30) do
85
+ Timeout.timeout(@request_timeout / 1000) do
87
86
  response_queue.pop
88
87
  end
89
88
  rescue Timeout::Error
90
89
  @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
91
- raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
90
+ raise RubyLLM::MCP::Errors::TimeoutError.new(
91
+ message: "Request timed out after #{@request_timeout / 1000} seconds"
92
+ )
92
93
  end
93
94
  end
94
- # rubocop:enable Metrics/MethodLength
95
+
96
+ def alive?
97
+ @running
98
+ end
95
99
 
96
100
  def close
97
101
  @running = false
@@ -11,7 +11,8 @@ module RubyLLM
11
11
  class Stdio
12
12
  attr_reader :command, :stdin, :stdout, :stderr, :id
13
13
 
14
- def initialize(command, args: [], env: {})
14
+ def initialize(command, request_timeout:, args: [], env: {})
15
+ @request_timeout = request_timeout
15
16
  @command = command
16
17
  @args = args
17
18
  @env = env || {}
@@ -23,6 +24,7 @@ module RubyLLM
23
24
  @pending_mutex = Mutex.new
24
25
  @running = true
25
26
  @reader_thread = nil
27
+ @stderr_thread = nil
26
28
 
27
29
  start_process
28
30
  end
@@ -53,16 +55,22 @@ module RubyLLM
53
55
  return unless wait_for_response
54
56
 
55
57
  begin
56
- Timeout.timeout(30) do
58
+ Timeout.timeout(@request_timeout / 1000) do
57
59
  response_queue.pop
58
60
  end
59
61
  rescue Timeout::Error
60
62
  @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
61
- raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
63
+ raise RubyLLM::MCP::Errors::TimeoutError.new(
64
+ message: "Request timed out after #{@request_timeout / 1000} seconds"
65
+ )
62
66
  end
63
67
  end
64
68
 
65
- def close
69
+ def alive?
70
+ @running
71
+ end
72
+
73
+ def close # rubocop:disable Metrics/MethodLength
66
74
  @running = false
67
75
 
68
76
  begin
@@ -82,6 +90,7 @@ module RubyLLM
82
90
  rescue StandardError
83
91
  nil
84
92
  end
93
+
85
94
  begin
86
95
  @stderr&.close
87
96
  rescue StandardError
@@ -94,11 +103,18 @@ module RubyLLM
94
103
  nil
95
104
  end
96
105
 
106
+ begin
107
+ @stderr_thread&.join(1)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
97
112
  @stdin = nil
98
113
  @stdout = nil
99
114
  @stderr = nil
100
115
  @wait_thread = nil
101
116
  @reader_thread = nil
117
+ @stderr_thread = nil
102
118
  end
103
119
 
104
120
  private
@@ -109,10 +125,11 @@ module RubyLLM
109
125
  @stdin, @stdout, @stderr, @wait_thread = if @env.empty?
110
126
  Open3.popen3(@command, *@args)
111
127
  else
112
- Open3.popen3(environment_string, @command, *@args)
128
+ Open3.popen3(@env, @command, *@args)
113
129
  end
114
130
 
115
131
  start_reader_thread
132
+ start_stderr_thread
116
133
  end
117
134
 
118
135
  def restart_process
@@ -148,12 +165,34 @@ module RubyLLM
148
165
  @reader_thread.abort_on_exception = true
149
166
  end
150
167
 
151
- def process_response(line)
152
- response = begin
153
- JSON.parse(line)
154
- rescue JSON::ParserError => e
155
- raise "Error parsing response as JSON: #{e.message}\nRaw response: #{line}"
168
+ def start_stderr_thread
169
+ @stderr_thread = Thread.new do
170
+ while @running
171
+ begin
172
+ if @stderr.closed? || @wait_thread.nil? || !@wait_thread.alive?
173
+ sleep 1
174
+ next
175
+ end
176
+
177
+ line = @stderr.gets
178
+ next unless line && !line.strip.empty?
179
+
180
+ puts "STDERR: #{line.strip}"
181
+ rescue IOError, Errno::EPIPE => e
182
+ puts "Stderr reader error: #{e.message}"
183
+ sleep 1
184
+ rescue StandardError => e
185
+ puts "Error in stderr thread: #{e.message}"
186
+ sleep 1
187
+ end
188
+ end
156
189
  end
190
+
191
+ @stderr_thread.abort_on_exception = true
192
+ end
193
+
194
+ def process_response(line)
195
+ response = JSON.parse(line)
157
196
  request_id = response["id"]&.to_s
158
197
 
159
198
  @pending_mutex.synchronize do
@@ -162,10 +201,8 @@ module RubyLLM
162
201
  response_queue&.push(response)
163
202
  end
164
203
  end
165
- end
166
-
167
- def environment_string
168
- @env.map { |key, value| "#{key}=#{value}" }.join(" ")
204
+ rescue JSON::ParserError => e
205
+ RubyLLM.logger.error("Error parsing response as JSON: #{e.message}\nRaw response: #{line}")
169
206
  end
170
207
  end
171
208
  end
@@ -12,8 +12,9 @@ module RubyLLM
12
12
  class Streamable
13
13
  attr_reader :headers, :id, :session_id
14
14
 
15
- def initialize(url, headers: {})
15
+ def initialize(url, request_timeout:, headers: {})
16
16
  @url = url
17
+ @request_timeout = request_timeout
17
18
  @client_id = SecureRandom.uuid
18
19
  @session_id = nil
19
20
  @base_headers = headers.merge({
@@ -55,6 +56,10 @@ module RubyLLM
55
56
  handle_response(response, request_id, response_queue, wait_for_response)
56
57
  end
57
58
 
59
+ def alive?
60
+ @running
61
+ end
62
+
58
63
  def close
59
64
  @running = false
60
65
  @sse_mutex.synchronize do
@@ -83,7 +88,7 @@ module RubyLLM
83
88
 
84
89
  def create_connection
85
90
  Faraday.new(url: @url) do |f|
86
- f.options.timeout = 300
91
+ f.options.timeout = @request_timeout / 1000
87
92
  f.options.open_timeout = 10
88
93
  end
89
94
  end
@@ -279,12 +284,14 @@ module RubyLLM
279
284
  end
280
285
 
281
286
  def wait_for_response_with_timeout(request_id, response_queue)
282
- Timeout.timeout(30) do
287
+ Timeout.timeout(@request_timeout / 1000) do
283
288
  response_queue.pop
284
289
  end
285
290
  rescue Timeout::Error
286
291
  @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
287
- raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
292
+ raise RubyLLM::MCP::Errors::TimeoutError.new(
293
+ message: "Request timed out after #{@request_timeout / 1000} seconds"
294
+ )
288
295
  end
289
296
  end
290
297
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module MCP
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end