ruby_llm-mcp 0.5.1 → 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.
@@ -8,6 +8,7 @@ module RubyLLM
8
8
  extend Forwardable
9
9
 
10
10
  attr_reader :name, :config, :transport_type, :request_timeout, :log_level, :on, :roots
11
+ attr_accessor :linked_resources
11
12
 
12
13
  def initialize(name:, transport_type:, start: true, request_timeout: MCP.config.request_timeout, config: {})
13
14
  @name = name
@@ -26,6 +27,8 @@ module RubyLLM
26
27
 
27
28
  @log_level = nil
28
29
 
30
+ @linked_resources = []
31
+
29
32
  setup_roots
30
33
  setup_sampling
31
34
 
@@ -72,7 +75,8 @@ module RubyLLM
72
75
 
73
76
  fetch(:resources, refresh) do
74
77
  resources = @coordinator.resource_list
75
- build_map(resources, MCP::Resource)
78
+ resources = build_map(resources, MCP::Resource)
79
+ include_linked_resources(resources)
76
80
  end
77
81
 
78
82
  @resources.values
@@ -157,7 +161,10 @@ module RubyLLM
157
161
  end
158
162
 
159
163
  def on_logging(level: Logging::WARNING, &block)
160
- @coordinator.set_logging(level: level)
164
+ @on_logging_level = level
165
+ if alive?
166
+ @coordinator.set_logging(level: level)
167
+ end
161
168
 
162
169
  @on[:logging] = block
163
170
  self
@@ -172,6 +179,37 @@ module RubyLLM
172
179
  self
173
180
  end
174
181
 
182
+ def elicitation_enabled?
183
+ @on.key?(:elicitation) && !@on[:elicitation].nil?
184
+ end
185
+
186
+ def on_elicitation(&block)
187
+ @on[:elicitation] = block
188
+ self
189
+ end
190
+
191
+ def to_h
192
+ {
193
+ name: @name,
194
+ transport_type: @transport_type,
195
+ request_timeout: @request_timeout,
196
+ start: @start,
197
+ config: @config,
198
+ on: @on,
199
+ tools: @tools,
200
+ resources: @resources,
201
+ resource_templates: @resource_templates,
202
+ prompts: @prompts,
203
+ log_level: @log_level
204
+ }
205
+ end
206
+
207
+ alias as_json to_h
208
+
209
+ def inspect
210
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} #{to_h.map { |k, v| "#{k}: #{v}" }.join(', ')}>"
211
+ end
212
+
175
213
  private
176
214
 
177
215
  def setup_coordinator
@@ -199,6 +237,14 @@ module RubyLLM
199
237
  end
200
238
  end
201
239
 
240
+ def include_linked_resources(resources)
241
+ @linked_resources.each do |resource|
242
+ resources[resource.name] = resource
243
+ end
244
+
245
+ resources
246
+ end
247
+
202
248
  def setup_roots
203
249
  @roots = Roots.new(paths: MCP.config.roots, coordinator: @coordinator)
204
250
  end
@@ -206,6 +252,14 @@ module RubyLLM
206
252
  def setup_sampling
207
253
  @on[:sampling] = MCP.config.sampling.guard
208
254
  end
255
+
256
+ def setup_event_handlers
257
+ @on[:progress] = MCP.config.on_progress
258
+ @on[:human_in_the_loop] = MCP.config.on_human_in_the_loop
259
+ @on[:logging] = MCP.config.on_logging
260
+ @on_logging_level = MCP.config.on_logging_level
261
+ @on[:elicitation] = MCP.config.on_elicitation
262
+ end
209
263
  end
210
264
  end
211
265
  end
@@ -3,9 +3,10 @@
3
3
  module RubyLLM
4
4
  module MCP
5
5
  class Completion
6
- attr_reader :values, :total, :has_more
6
+ attr_reader :argument, :values, :total, :has_more
7
7
 
8
- def initialize(values:, total:, has_more:)
8
+ def initialize(argument:, values:, total:, has_more:)
9
+ @argument = argument
9
10
  @values = values
10
11
  @total = total
11
12
  @has_more = has_more
@@ -92,8 +92,10 @@ module RubyLLM
92
92
  :sampling,
93
93
  :max_connections,
94
94
  :pool_timeout,
95
+ :protocol_version,
95
96
  :config_path,
96
- :launch_control
97
+ :launch_control,
98
+ :on_logging_level
97
99
 
98
100
  attr_writer :logger, :mcp_configuration
99
101
 
@@ -127,6 +129,26 @@ module RubyLLM
127
129
  @mcp_configuration + load_mcps_config
128
130
  end
129
131
 
132
+ def on_progress(&block)
133
+ @on_progress = block if block_given?
134
+ @on_progress
135
+ end
136
+
137
+ def on_human_in_the_loop(&block)
138
+ @on_human_in_the_loop = block if block_given?
139
+ @on_human_in_the_loop
140
+ end
141
+
142
+ def on_logging(&block)
143
+ @on_logging = block if block_given?
144
+ @on_logging
145
+ end
146
+
147
+ def on_elicitation(&block)
148
+ @on_elicitation = block if block_given?
149
+ @on_elicitation
150
+ end
151
+
130
152
  def inspect
131
153
  redacted = lambda do |name, value|
132
154
  if name.match?(/_id|_key|_secret|_token$/)
@@ -180,6 +202,13 @@ module RubyLLM
180
202
 
181
203
  # Sampling configuration
182
204
  @sampling.reset!
205
+
206
+ # Event handlers
207
+ @on_progress = nil
208
+ @on_human_in_the_loop = nil
209
+ @on_elicitation = nil
210
+ @on_logging_level = nil
211
+ @on_logging = nil
183
212
  end
184
213
  end
185
214
  end
@@ -5,9 +5,6 @@ require "logger"
5
5
  module RubyLLM
6
6
  module MCP
7
7
  class Coordinator
8
- PROTOCOL_VERSION = "2025-03-26"
9
- PV_2024_11_05 = "2024-11-05"
10
-
11
8
  attr_reader :client, :transport_type, :config, :capabilities, :protocol_version
12
9
 
13
10
  def initialize(client, transport_type:, config: {})
@@ -15,7 +12,7 @@ module RubyLLM
15
12
  @transport_type = transport_type
16
13
  @config = config
17
14
 
18
- @protocol_version = PROTOCOL_VERSION
15
+ @protocol_version = MCP.config.protocol_version || MCP::Protocol.default_negotiated_version
19
16
 
20
17
  @transport = nil
21
18
  @capabilities = nil
@@ -28,7 +25,7 @@ module RubyLLM
28
25
  def request(body, **options)
29
26
  transport.request(body, **options)
30
27
  rescue RubyLLM::MCP::Errors::TimeoutError => e
31
- if transport&.alive?
28
+ if transport&.alive? && !e.request_id.nil?
32
29
  cancelled_notification(reason: "Request timed out", request_id: e.request_id)
33
30
  end
34
31
  raise e
@@ -62,6 +59,16 @@ module RubyLLM
62
59
 
63
60
  # Extract and store the negotiated protocol version
64
61
  negotiated_version = initialize_response.value["protocolVersion"]
62
+
63
+ if negotiated_version && !MCP::Protocol.supported_version?(negotiated_version)
64
+ raise Errors::UnsupportedProtocolVersion.new(
65
+ message: <<~MESSAGE
66
+ Unsupported protocol version, and could not negotiate a supported version: #{negotiated_version}.
67
+ Supported versions: #{MCP::Protocol.supported_versions.join(', ')}
68
+ MESSAGE
69
+ )
70
+ end
71
+
65
72
  @protocol_version = negotiated_version if negotiated_version
66
73
 
67
74
  # Set the protocol version on the transport for subsequent requests
@@ -71,13 +78,17 @@ module RubyLLM
71
78
 
72
79
  @capabilities = RubyLLM::MCP::ServerCapabilities.new(initialize_response.value["capabilities"])
73
80
  initialize_notification
81
+
82
+ if client.logging_handler_enabled?
83
+ set_logging(level: client.on_logging_level)
84
+ end
74
85
  end
75
86
 
76
87
  def stop_transport
77
88
  @transport&.close
78
89
  @capabilities = nil
79
90
  @transport = nil
80
- @protocol_version = PROTOCOL_VERSION
91
+ @protocol_version = MCP::Protocol.default_negotiated_version
81
92
  end
82
93
 
83
94
  def restart_transport
@@ -150,6 +161,11 @@ module RubyLLM
150
161
  RubyLLM::MCP::Requests::ToolCall.new(self, **args).call
151
162
  end
152
163
 
164
+ def register_resource(resource)
165
+ @client.linked_resources << resource
166
+ @client.resources[resource.name] = resource
167
+ end
168
+
153
169
  def resource_list(cursor: nil)
154
170
  result = RubyLLM::MCP::Requests::ResourceList.new(self, cursor: cursor).call
155
171
  result.raise_error! if result.error?
@@ -239,6 +255,10 @@ module RubyLLM
239
255
  RubyLLM::MCP::Responses::Error.new(self, **args).call
240
256
  end
241
257
 
258
+ def elicitation_response(**args)
259
+ RubyLLM::MCP::Responses::Elicitation.new(self, **args).call
260
+ end
261
+
242
262
  def client_capabilities
243
263
  capabilities = {}
244
264
 
@@ -252,6 +272,10 @@ module RubyLLM
252
272
  capabilities[:sampling] = {}
253
273
  end
254
274
 
275
+ if client.elicitation_enabled?
276
+ capabilities[:elicitation] = {}
277
+ end
278
+
255
279
  capabilities
256
280
  end
257
281
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json-schema"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ class Elicitation
8
+ ACCEPT_ACTION = "accept"
9
+ CANCEL_ACTION = "cancel"
10
+ REJECT_ACTION = "reject"
11
+
12
+ attr_writer :structured_response
13
+
14
+ def initialize(coordinator, result)
15
+ @coordinator = coordinator
16
+ @result = result
17
+ @id = result.id
18
+
19
+ @message = @result.params["message"]
20
+ @requested_schema = @result.params["requestedSchema"]
21
+ end
22
+
23
+ def execute
24
+ success = @coordinator.client.on[:elicitation].call(self)
25
+ if success
26
+ valid = validate_response
27
+ if valid
28
+ @coordinator.elicitation_response(id: @id, action: ACCEPT_ACTION, content: @structured_response)
29
+ else
30
+ @coordinator.elicitation_response(id: @id, action: CANCEL_ACTION, content: nil)
31
+ end
32
+ else
33
+ @coordinator.elicitation_response(id: @id, action: REJECT_ACTION, content: nil)
34
+ end
35
+ end
36
+
37
+ def message
38
+ @result.params["message"]
39
+ end
40
+
41
+ def validate_response
42
+ JSON::Validator.validate(@requested_schema, @structured_response)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -58,6 +58,8 @@ module RubyLLM
58
58
  end
59
59
 
60
60
  class UnknownRequest < BaseError; end
61
+
62
+ class UnsupportedProtocolVersion < BaseError; end
61
63
  end
62
64
  end
63
65
  end
@@ -50,16 +50,17 @@ module RubyLLM
50
50
 
51
51
  alias say ask
52
52
 
53
- def complete(argument, value)
53
+ def complete(argument, value, context: nil)
54
54
  if @coordinator.capabilities.completion?
55
- result = @coordinator.completion_prompt(name: @name, argument: argument, value: value)
55
+ result = @coordinator.completion_prompt(name: @name, argument: argument, value: value, context: context)
56
56
  if result.error?
57
57
  return result.to_error
58
58
  end
59
59
 
60
60
  response = result.value["completion"]
61
61
 
62
- Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
62
+ Completion.new(argument: argument, values: response["values"], total: response["total"],
63
+ has_more: response["hasMore"])
63
64
  else
64
65
  message = "Completion is not available for this MCP server"
65
66
  raise Errors::Capabilities::CompletionNotAvailable.new(message: message)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Protocol
6
+ module_function
7
+
8
+ LATEST_PROTOCOL_VERSION = "2025-06-18"
9
+ DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
10
+ SUPPORTED_PROTOCOL_VERSIONS = [
11
+ LATEST_PROTOCOL_VERSION,
12
+ "2025-03-26",
13
+ "2024-11-05",
14
+ "2024-10-07"
15
+ ].freeze
16
+
17
+ def supported_version?(version)
18
+ SUPPORTED_PROTOCOL_VERSIONS.include?(version)
19
+ end
20
+
21
+ def supported_versions
22
+ SUPPORTED_PROTOCOL_VERSIONS
23
+ end
24
+
25
+ def latest_version
26
+ LATEST_PROTOCOL_VERSION
27
+ end
28
+
29
+ def default_negotiated_version
30
+ DEFAULT_NEGOTIATED_PROTOCOL_VERSION
31
+ end
32
+ end
33
+ end
34
+ end
@@ -4,11 +4,12 @@ module RubyLLM
4
4
  module MCP
5
5
  module Requests
6
6
  class CompletionPrompt
7
- def initialize(coordinator, name:, argument:, value:)
7
+ def initialize(coordinator, name:, argument:, value:, context: nil)
8
8
  @coordinator = coordinator
9
9
  @name = name
10
10
  @argument = argument
11
11
  @value = value
12
+ @context = context
12
13
  end
13
14
 
14
15
  def call
@@ -30,8 +31,17 @@ module RubyLLM
30
31
  argument: {
31
32
  name: @argument,
32
33
  value: @value
33
- }
34
- }
34
+ },
35
+ context: format_context
36
+ }.compact
37
+ }
38
+ end
39
+
40
+ def format_context
41
+ return nil if @context.nil?
42
+
43
+ {
44
+ arguments: @context
35
45
  }
36
46
  end
37
47
  end
@@ -4,11 +4,12 @@ module RubyLLM
4
4
  module MCP
5
5
  module Requests
6
6
  class CompletionResource
7
- def initialize(coordinator, uri:, argument:, value:)
7
+ def initialize(coordinator, uri:, argument:, value:, context: nil)
8
8
  @coordinator = coordinator
9
9
  @uri = uri
10
10
  @argument = argument
11
11
  @value = value
12
+ @context = context
12
13
  end
13
14
 
14
15
  def call
@@ -30,8 +31,17 @@ module RubyLLM
30
31
  argument: {
31
32
  name: @argument,
32
33
  value: @value
33
- }
34
- }
34
+ },
35
+ context: format_context
36
+ }.compact
37
+ }
38
+ end
39
+
40
+ def format_context
41
+ return nil if @context.nil?
42
+
43
+ {
44
+ arguments: @context
35
45
  }
36
46
  end
37
47
  end
@@ -61,7 +61,6 @@ module RubyLLM
61
61
 
62
62
  def to_content
63
63
  content = self.content
64
-
65
64
  case content_type
66
65
  when "text"
67
66
  MCP::Content.new(text: "#{name}: #{description}\n\n#{content}")
@@ -89,7 +88,7 @@ module RubyLLM
89
88
  def content_type
90
89
  return "text" if @content_response.nil?
91
90
 
92
- if @content_response.key?("blob")
91
+ if @content_response.key?("blob") && !@content_response["blob"].nil?
93
92
  "blob"
94
93
  else
95
94
  "text"
@@ -33,14 +33,15 @@ module RubyLLM
33
33
  fetch_resource(arguments: arguments).to_content
34
34
  end
35
35
 
36
- def complete(argument, value)
36
+ def complete(argument, value, context: nil)
37
37
  if @coordinator.capabilities.completion?
38
- result = @coordinator.completion_resource(uri: @uri, argument: argument, value: value)
38
+ result = @coordinator.completion_resource(uri: @uri, argument: argument, value: value, context: context)
39
39
  result.raise_error! if result.error?
40
40
 
41
41
  response = result.value["completion"]
42
42
 
43
- Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
43
+ Completion.new(argument: argument, values: response["values"], total: response["total"],
44
+ has_more: response["hasMore"])
44
45
  else
45
46
  message = "Completion is not available for this MCP server"
46
47
  raise Errors::Capabilities::CompletionNotAvailable.new(message: message)
@@ -20,6 +20,9 @@ module RubyLLM
20
20
  elsif result.sampling?
21
21
  handle_sampling_response(result)
22
22
  true
23
+ elsif result.elicitation?
24
+ handle_elicitation_response(result)
25
+ true
23
26
  else
24
27
  handle_unknown_request(result)
25
28
  RubyLLM::MCP.logger.error("MCP client was sent unknown method type and could not respond: #{result.inspect}")
@@ -30,6 +33,7 @@ module RubyLLM
30
33
  private
31
34
 
32
35
  def handle_roots_response(result)
36
+ RubyLLM::MCP.logger.info("Roots request: #{result.inspect}")
33
37
  if client.roots.active?
34
38
  coordinator.roots_list_response(id: result.id, roots: client.roots)
35
39
  else
@@ -44,10 +48,15 @@ module RubyLLM
44
48
  return
45
49
  end
46
50
 
47
- RubyLLM::MCP.logger.info("Sampling response: #{result.inspect}")
51
+ RubyLLM::MCP.logger.info("Sampling request: #{result.inspect}")
48
52
  Sample.new(result, coordinator).execute
49
53
  end
50
54
 
55
+ def handle_elicitation_response(result)
56
+ RubyLLM::MCP.logger.info("Elicitation request: #{result.inspect}")
57
+ Elicitation.new(coordinator, result).execute
58
+ end
59
+
51
60
  def handle_unknown_request(result)
52
61
  coordinator.error_response(id: result.id,
53
62
  message: "Unknown method and could not respond: #{result.method}",
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Responses
6
+ class Elicitation
7
+ def initialize(coordinator, id:, action:, content:)
8
+ @coordinator = coordinator
9
+ @id = id
10
+ @action = action
11
+ @content = content
12
+ end
13
+
14
+ def call
15
+ @coordinator.request(elicitation_response_body, add_id: false, wait_for_response: false)
16
+ end
17
+
18
+ private
19
+
20
+ def elicitation_response_body
21
+ {
22
+ jsonrpc: "2.0",
23
+ id: @id,
24
+ result: {
25
+ action: @action,
26
+ content: @content
27
+ }.compact
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -17,7 +17,8 @@ module RubyLLM
17
17
  REQUEST_METHODS = {
18
18
  ping: "ping",
19
19
  roots: "roots/list",
20
- sampling: "sampling/createMessage"
20
+ sampling: "sampling/createMessage",
21
+ elicitation: "elicitation/create"
21
22
  }.freeze
22
23
 
23
24
  def initialize(response, session_id: nil)
@@ -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(input_schema)
95
+ def create_parameters(schema)
83
96
  params = {}
84
- return params if input_schema["properties"].nil?
97
+ return params if schema["properties"].nil?
85
98
 
86
- input_schema["properties"].each_key do |key|
87
- param_data = input_schema.dig("properties", key)
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
- "content" => content["resource"]
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