ruby_llm-mcp 0.3.1 → 0.4.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +121 -2
  3. data/lib/ruby_llm/mcp/capabilities.rb +22 -2
  4. data/lib/ruby_llm/mcp/client.rb +106 -18
  5. data/lib/ruby_llm/mcp/configuration.rb +66 -0
  6. data/lib/ruby_llm/mcp/coordinator.rb +197 -33
  7. data/lib/ruby_llm/mcp/error.rb +34 -0
  8. data/lib/ruby_llm/mcp/errors.rb +37 -4
  9. data/lib/ruby_llm/mcp/logging.rb +16 -0
  10. data/lib/ruby_llm/mcp/parameter.rb +2 -0
  11. data/lib/ruby_llm/mcp/progress.rb +33 -0
  12. data/lib/ruby_llm/mcp/prompt.rb +12 -5
  13. data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +5 -2
  14. data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +6 -3
  15. data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +6 -3
  16. data/lib/ruby_llm/mcp/requests/base.rb +3 -3
  17. data/lib/ruby_llm/mcp/requests/cancelled_notification.rb +32 -0
  18. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +3 -3
  19. data/lib/ruby_llm/mcp/requests/completion_resource.rb +3 -3
  20. data/lib/ruby_llm/mcp/requests/initialization.rb +24 -18
  21. data/lib/ruby_llm/mcp/requests/initialize_notification.rb +15 -9
  22. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
  23. data/lib/ruby_llm/mcp/requests/meta.rb +30 -0
  24. data/lib/ruby_llm/mcp/requests/ping.rb +20 -0
  25. data/lib/ruby_llm/mcp/requests/ping_response.rb +28 -0
  26. data/lib/ruby_llm/mcp/requests/prompt_call.rb +3 -3
  27. data/lib/ruby_llm/mcp/requests/prompt_list.rb +1 -1
  28. data/lib/ruby_llm/mcp/requests/resource_list.rb +1 -1
  29. data/lib/ruby_llm/mcp/requests/resource_read.rb +4 -4
  30. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +1 -1
  31. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
  32. data/lib/ruby_llm/mcp/requests/tool_call.rb +6 -3
  33. data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -11
  34. data/lib/ruby_llm/mcp/resource.rb +26 -5
  35. data/lib/ruby_llm/mcp/resource_template.rb +11 -6
  36. data/lib/ruby_llm/mcp/result.rb +90 -0
  37. data/lib/ruby_llm/mcp/tool.rb +28 -3
  38. data/lib/ruby_llm/mcp/transport/sse.rb +81 -75
  39. data/lib/ruby_llm/mcp/transport/stdio.rb +33 -17
  40. data/lib/ruby_llm/mcp/transport/streamable_http.rb +647 -0
  41. data/lib/ruby_llm/mcp/version.rb +1 -1
  42. data/lib/ruby_llm/mcp.rb +18 -0
  43. data/lib/tasks/release.rake +23 -0
  44. metadata +20 -50
  45. data/lib/ruby_llm/mcp/transport/streamable.rb +0 -299
@@ -1,24 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class RubyLLM::MCP::Requests::Initialization < RubyLLM::MCP::Requests::Base
4
- def call
5
- client.request(initialize_body)
6
- end
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class Initialization < RubyLLM::MCP::Requests::Base
7
+ def call
8
+ coordinator.request(initialize_body)
9
+ end
7
10
 
8
- private
11
+ private
9
12
 
10
- def initialize_body
11
- {
12
- jsonrpc: "2.0",
13
- method: "initialize",
14
- params: {
15
- protocolVersion: @client.protocol_version,
16
- capabilities: {},
17
- clientInfo: {
18
- name: "RubyLLM-MCP Client",
19
- version: RubyLLM::MCP::VERSION
20
- }
21
- }
22
- }
13
+ def initialize_body
14
+ {
15
+ jsonrpc: "2.0",
16
+ method: "initialize",
17
+ params: {
18
+ protocolVersion: coordinator.protocol_version,
19
+ capabilities: {},
20
+ clientInfo: {
21
+ name: "RubyLLM-MCP Client",
22
+ version: RubyLLM::MCP::VERSION
23
+ }
24
+ }
25
+ }
26
+ end
27
+ end
28
+ end
23
29
  end
24
30
  end
@@ -1,14 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class RubyLLM::MCP::Requests::InitializeNotification < RubyLLM::MCP::Requests::Base
4
- def call
5
- client.request(notification_body, add_id: false, wait_for_response: false)
6
- end
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class InitializeNotification < RubyLLM::MCP::Requests::Base
7
+ def call
8
+ coordinator.request(notification_body, add_id: false, wait_for_response: false)
9
+ end
7
10
 
8
- def notification_body
9
- {
10
- jsonrpc: "2.0",
11
- method: "notifications/initialized"
12
- }
11
+ def notification_body
12
+ {
13
+ jsonrpc: "2.0",
14
+ method: "notifications/initialized"
15
+ }
16
+ end
17
+ end
18
+ end
13
19
  end
14
20
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class LoggingSetLevel
7
+ def initialize(coordinator, level:)
8
+ @coordinator = coordinator
9
+ @level = level
10
+ end
11
+
12
+ def call
13
+ @coordinator.request(logging_set_body)
14
+ end
15
+
16
+ def logging_set_body
17
+ {
18
+ jsonrpc: "2.0",
19
+ method: "logging/setLevel",
20
+ params: {
21
+ level: @level
22
+ }
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Requests
8
+ module Meta
9
+ def merge_meta(body)
10
+ meta = {}
11
+ meta.merge!(progress_token) if @coordinator.client.tracking_progress?
12
+
13
+ body[:params] ||= {}
14
+ body[:params].merge!({ _meta: meta }) unless meta.empty?
15
+ body
16
+ end
17
+
18
+ private
19
+
20
+ def progress_token
21
+ { progressToken: generate_progress_token }
22
+ end
23
+
24
+ def generate_progress_token
25
+ SecureRandom.uuid
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class Ping < Base
7
+ def call
8
+ coordinator.request(ping_body)
9
+ end
10
+
11
+ def ping_body
12
+ {
13
+ jsonrpc: "2.0",
14
+ method: "ping"
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class PingResponse
7
+ def initialize(coordinator, id:)
8
+ @coordinator = coordinator
9
+ @id = id
10
+ end
11
+
12
+ def call
13
+ @coordinator.request(ping_response_body, add_id: false, wait_for_response: false)
14
+ end
15
+
16
+ private
17
+
18
+ def ping_response_body
19
+ {
20
+ jsonrpc: "2.0",
21
+ id: @id,
22
+ result: {}
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,14 +4,14 @@ module RubyLLM
4
4
  module MCP
5
5
  module Requests
6
6
  class PromptCall
7
- def initialize(client, name:, arguments: {})
8
- @client = client
7
+ def initialize(coordinator, name:, arguments: {})
8
+ @coordinator = coordinator
9
9
  @name = name
10
10
  @arguments = arguments
11
11
  end
12
12
 
13
13
  def call
14
- @client.request(request_body)
14
+ @coordinator.request(request_body)
15
15
  end
16
16
 
17
17
  private
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  module Requests
6
6
  class PromptList < Base
7
7
  def call
8
- client.request(request_body)
8
+ coordinator.request(request_body)
9
9
  end
10
10
 
11
11
  private
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  module Requests
6
6
  class ResourceList < Base
7
7
  def call
8
- client.request(resource_list_body)
8
+ coordinator.request(resource_list_body)
9
9
  end
10
10
 
11
11
  def resource_list_body
@@ -4,15 +4,15 @@ module RubyLLM
4
4
  module MCP
5
5
  module Requests
6
6
  class ResourceRead
7
- attr_reader :client, :uri
7
+ attr_reader :coordinator, :uri
8
8
 
9
- def initialize(client, uri:)
10
- @client = client
9
+ def initialize(coordinator, uri:)
10
+ @coordinator = coordinator
11
11
  @uri = uri
12
12
  end
13
13
 
14
14
  def call
15
- client.request(reading_resource_body(uri))
15
+ coordinator.request(reading_resource_body(uri))
16
16
  end
17
17
 
18
18
  def reading_resource_body(uri)
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  module Requests
6
6
  class ResourceTemplateList < Base
7
7
  def call
8
- client.request(resource_template_list_body)
8
+ coordinator.request(resource_template_list_body)
9
9
  end
10
10
 
11
11
  def resource_template_list_body
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class ResourcesSubscribe
7
+ def initialize(coordinator, uri:)
8
+ @coordinator = coordinator
9
+ @uri = uri
10
+ end
11
+
12
+ def call
13
+ @coordinator.request(resources_subscribe_body, wait_for_response: false)
14
+ end
15
+
16
+ private
17
+
18
+ def resources_subscribe_body
19
+ {
20
+ jsonrpc: "2.0",
21
+ method: "resources/subscribe",
22
+ params: {
23
+ uri: @uri
24
+ }
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -4,14 +4,17 @@ module RubyLLM
4
4
  module MCP
5
5
  module Requests
6
6
  class ToolCall
7
- def initialize(client, name:, parameters: {})
8
- @client = client
7
+ include Meta
8
+
9
+ def initialize(coordinator, name:, parameters: {})
10
+ @coordinator = coordinator
9
11
  @name = name
10
12
  @parameters = parameters
11
13
  end
12
14
 
13
15
  def call
14
- @client.request(request_body)
16
+ body = merge_meta(request_body)
17
+ @coordinator.request(body)
15
18
  end
16
19
 
17
20
  private
@@ -1,17 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class RubyLLM::MCP::Requests::ToolList < RubyLLM::MCP::Requests::Base
4
- def call
5
- client.request(tool_list_body)
6
- end
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class ToolList < RubyLLM::MCP::Requests::Base
7
+ def call
8
+ coordinator.request(tool_list_body)
9
+ end
7
10
 
8
- private
11
+ private
9
12
 
10
- def tool_list_body
11
- {
12
- jsonrpc: "2.0",
13
- method: "tools/list",
14
- params: {}
15
- }
13
+ def tool_list_body
14
+ {
15
+ jsonrpc: "2.0",
16
+ method: "tools/list",
17
+ params: {}
18
+ }
19
+ end
20
+ end
21
+ end
16
22
  end
17
23
  end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "httpx"
4
+
3
5
  module RubyLLM
4
6
  module MCP
5
7
  class Resource
6
- attr_reader :uri, :name, :description, :mime_type, :coordinator
8
+ attr_reader :uri, :name, :description, :mime_type, :coordinator, :subscribed
7
9
 
8
10
  def initialize(coordinator, resource)
9
11
  @coordinator = coordinator
@@ -15,16 +17,35 @@ module RubyLLM
15
17
  @content_response = resource["content_response"]
16
18
  @content = @content_response["text"] || @content_response["blob"]
17
19
  end
20
+
21
+ @subscribed = false
18
22
  end
19
23
 
20
24
  def content
21
25
  return @content unless @content.nil?
22
26
 
23
- response = read_response
24
- @content_response = response.dig("result", "contents", 0)
27
+ result = read_response
28
+ result.raise_error! if result.error?
29
+
30
+ @content_response = result.value.dig("contents", 0)
25
31
  @content = @content_response["text"] || @content_response["blob"]
26
32
  end
27
33
 
34
+ def subscribe!
35
+ if @coordinator.capabilities.resource_subscribe?
36
+ @coordinator.resources_subscribe(uri: @uri)
37
+ @subscribed = true
38
+ else
39
+ message = "Resource subscribe is not available for this MCP server"
40
+ raise Errors::Capabilities::ResourceSubscribeNotAvailable.new(message: message)
41
+ end
42
+ end
43
+
44
+ def reset_content!
45
+ @content = nil
46
+ @content_response = nil
47
+ end
48
+
28
49
  def include(chat, **args)
29
50
  message = Message.new(
30
51
  role: "user",
@@ -64,12 +85,12 @@ module RubyLLM
64
85
  when "http", "https"
65
86
  fetch_uri_content(uri)
66
87
  else # file:// or git://
67
- @read_response ||= @coordinator.resource_read(uri: uri)
88
+ @coordinator.resource_read(uri: uri)
68
89
  end
69
90
  end
70
91
 
71
92
  def fetch_uri_content(uri)
72
- response = Faraday.get(uri)
93
+ response = HTTPX.get(uri)
73
94
  { "result" => { "contents" => [{ "text" => response.body }] } }
74
95
  end
75
96
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "httpx"
4
+
3
5
  module RubyLLM
4
6
  module MCP
5
7
  class ResourceTemplate
@@ -15,8 +17,8 @@ module RubyLLM
15
17
 
16
18
  def fetch_resource(arguments: {})
17
19
  uri = apply_template(@uri, arguments)
18
- response = read_response(uri)
19
- content_response = response.dig("result", "contents", 0)
20
+ result = read_response(uri)
21
+ content_response = result.value.dig("contents", 0)
20
22
 
21
23
  Resource.new(coordinator, {
22
24
  "uri" => uri,
@@ -33,12 +35,15 @@ module RubyLLM
33
35
 
34
36
  def complete(argument, value)
35
37
  if @coordinator.capabilities.completion?
36
- response = @coordinator.completion_resource(uri: @uri, argument: argument, value: value)
37
- response = response.dig("result", "completion")
38
+ result = @coordinator.completion_resource(uri: @uri, argument: argument, value: value)
39
+ result.raise_error! if result.error?
40
+
41
+ response = result.value["completion"]
38
42
 
39
43
  Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
40
44
  else
41
- raise Errors::CompletionNotAvailable.new(message: "Completion is not available for this MCP server")
45
+ message = "Completion is not available for this MCP server"
46
+ raise Errors::Capabilities::CompletionNotAvailable.new(message: message)
42
47
  end
43
48
  end
44
49
 
@@ -63,7 +68,7 @@ module RubyLLM
63
68
  end
64
69
 
65
70
  def fetch_uri_content(uri)
66
- response = Faraday.get(uri)
71
+ response = HTTPX.get(uri)
67
72
  { "result" => { "contents" => [{ "text" => response.body }] } }
68
73
  end
69
74
 
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Notification
6
+ attr_reader :type, :params
7
+
8
+ def initialize(response)
9
+ @type = response["method"]
10
+ @params = response["params"]
11
+ end
12
+ end
13
+
14
+ class Result
15
+ attr_reader :result, :error, :params, :id, :response, :session_id
16
+
17
+ def initialize(response, session_id: nil)
18
+ @response = response
19
+ @session_id = session_id
20
+ @id = response["id"]
21
+ @method = response["method"]
22
+ @result = response["result"] || {}
23
+ @params = response["params"] || {}
24
+ @error = response["error"] || {}
25
+
26
+ @result_is_error = response.dig("result", "isError") || false
27
+ end
28
+
29
+ alias value result
30
+
31
+ def notification
32
+ Notification.new(@response)
33
+ end
34
+
35
+ def to_error
36
+ Error.new(@error)
37
+ end
38
+
39
+ def execution_error?
40
+ @result_is_error
41
+ end
42
+
43
+ def raise_error!
44
+ error = to_error
45
+ message = "Response error: #{error}"
46
+ raise Errors::ResponseError.new(message: message, error: error)
47
+ end
48
+
49
+ def matching_id?(request_id)
50
+ @id&.to_s == request_id
51
+ end
52
+
53
+ def ping?
54
+ @method == "ping"
55
+ end
56
+
57
+ def notification?
58
+ @method&.include?("notifications") || false
59
+ end
60
+
61
+ def request?
62
+ @method && !notification? && @result.none? && @error.none?
63
+ end
64
+
65
+ def response?
66
+ @id && (@result || @error.any?) && !@method
67
+ end
68
+
69
+ def success?
70
+ !@result.empty?
71
+ end
72
+
73
+ def tool_success?
74
+ success? && !@result_is_error
75
+ end
76
+
77
+ def error?
78
+ !@error.empty?
79
+ end
80
+
81
+ def to_s
82
+ inspect
83
+ end
84
+
85
+ def inspect
86
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} id: #{@id}, result: #{@result}, error: #{@error}, method: #{@method}, params: #{@params}>" # rubocop:disable Layout/LineLength
87
+ end
88
+ end
89
+ end
90
+ end
@@ -2,6 +2,18 @@
2
2
 
3
3
  module RubyLLM
4
4
  module MCP
5
+ class Annotation
6
+ attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint
7
+
8
+ def initialize(annotation)
9
+ @title = annotation["title"] || ""
10
+ @read_only_hint = annotation["readOnlyHint"] || false
11
+ @destructive_hint = annotation["destructiveHint"] || true
12
+ @idempotent_hint = annotation["idempotentHint"] || false
13
+ @open_world_hint = annotation["openWorldHint"] || true
14
+ end
15
+ end
16
+
5
17
  class Tool < RubyLLM::Tool
6
18
  attr_reader :name, :description, :parameters, :coordinator, :tool_response
7
19
 
@@ -12,18 +24,31 @@ module RubyLLM
12
24
  @name = tool_response["name"]
13
25
  @description = tool_response["description"].to_s
14
26
  @parameters = create_parameters(tool_response["inputSchema"])
27
+ @annotations = tool_response["annotations"] ? Annotation.new(tool_response["annotations"]) : nil
28
+ end
29
+
30
+ def display_name
31
+ "#{@coordinator.name}: #{@name}"
15
32
  end
16
33
 
17
34
  def execute(**params)
18
- response = @coordinator.execute_tool(
35
+ result = @coordinator.execute_tool(
19
36
  name: @name,
20
37
  parameters: params
21
38
  )
22
39
 
23
- text_values = response.dig("result", "content").map { |content| content["text"] }.compact.join("\n")
40
+ if result.error?
41
+ error = result.to_error
42
+ return { error: error.to_s }
43
+ end
44
+
45
+ text_values = result.value["content"].map { |content| content["text"] }.compact.join("\n")
46
+ if result.execution_error?
47
+ return { error: "Tool execution error: #{text_values}" }
48
+ end
24
49
 
25
50
  if text_values.empty?
26
- create_content_for_message(response.dig("result", "content", 0))
51
+ create_content_for_message(result.value.dig("content", 0))
27
52
  else
28
53
  create_content_for_message({ "type" => "text", "text" => text_values })
29
54
  end