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.
- checksums.yaml +4 -4
- data/README.md +121 -2
- data/lib/ruby_llm/mcp/capabilities.rb +22 -2
- data/lib/ruby_llm/mcp/client.rb +106 -18
- data/lib/ruby_llm/mcp/configuration.rb +66 -0
- data/lib/ruby_llm/mcp/coordinator.rb +197 -33
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +37 -4
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/parameter.rb +2 -0
- data/lib/ruby_llm/mcp/progress.rb +33 -0
- data/lib/ruby_llm/mcp/prompt.rb +12 -5
- data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +5 -2
- data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +6 -3
- data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +6 -3
- data/lib/ruby_llm/mcp/requests/base.rb +3 -3
- data/lib/ruby_llm/mcp/requests/cancelled_notification.rb +32 -0
- data/lib/ruby_llm/mcp/requests/completion_prompt.rb +3 -3
- data/lib/ruby_llm/mcp/requests/completion_resource.rb +3 -3
- data/lib/ruby_llm/mcp/requests/initialization.rb +24 -18
- data/lib/ruby_llm/mcp/requests/initialize_notification.rb +15 -9
- data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
- data/lib/ruby_llm/mcp/requests/meta.rb +30 -0
- data/lib/ruby_llm/mcp/requests/ping.rb +20 -0
- data/lib/ruby_llm/mcp/requests/ping_response.rb +28 -0
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +3 -3
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resource_list.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resource_read.rb +4 -4
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
- data/lib/ruby_llm/mcp/requests/tool_call.rb +6 -3
- data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -11
- data/lib/ruby_llm/mcp/resource.rb +26 -5
- data/lib/ruby_llm/mcp/resource_template.rb +11 -6
- data/lib/ruby_llm/mcp/result.rb +90 -0
- data/lib/ruby_llm/mcp/tool.rb +28 -3
- data/lib/ruby_llm/mcp/transport/sse.rb +81 -75
- data/lib/ruby_llm/mcp/transport/stdio.rb +33 -17
- data/lib/ruby_llm/mcp/transport/streamable_http.rb +647 -0
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +18 -0
- data/lib/tasks/release.rake +23 -0
- metadata +20 -50
- data/lib/ruby_llm/mcp/transport/streamable.rb +0 -299
@@ -1,24 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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
|
-
|
11
|
+
private
|
9
12
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
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(
|
8
|
-
@
|
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
|
-
@
|
14
|
+
@coordinator.request(request_body)
|
15
15
|
end
|
16
16
|
|
17
17
|
private
|
@@ -4,15 +4,15 @@ module RubyLLM
|
|
4
4
|
module MCP
|
5
5
|
module Requests
|
6
6
|
class ResourceRead
|
7
|
-
attr_reader :
|
7
|
+
attr_reader :coordinator, :uri
|
8
8
|
|
9
|
-
def initialize(
|
10
|
-
@
|
9
|
+
def initialize(coordinator, uri:)
|
10
|
+
@coordinator = coordinator
|
11
11
|
@uri = uri
|
12
12
|
end
|
13
13
|
|
14
14
|
def call
|
15
|
-
|
15
|
+
coordinator.request(reading_resource_body(uri))
|
16
16
|
end
|
17
17
|
|
18
18
|
def reading_resource_body(uri)
|
@@ -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
|
-
|
8
|
-
|
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
|
-
|
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
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
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
|
-
|
11
|
+
private
|
9
12
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
-
|
24
|
-
|
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
|
-
@
|
88
|
+
@coordinator.resource_read(uri: uri)
|
68
89
|
end
|
69
90
|
end
|
70
91
|
|
71
92
|
def fetch_uri_content(uri)
|
72
|
-
response =
|
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
|
-
|
19
|
-
content_response =
|
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
|
-
|
37
|
-
|
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
|
-
|
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 =
|
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
|
data/lib/ruby_llm/mcp/tool.rb
CHANGED
@@ -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
|
-
|
35
|
+
result = @coordinator.execute_tool(
|
19
36
|
name: @name,
|
20
37
|
parameters: params
|
21
38
|
)
|
22
39
|
|
23
|
-
|
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(
|
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
|