ruby_llm-mcp 0.0.1 → 0.1.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 +38 -4
- data/lib/ruby_llm/mcp/capabilities.rb +29 -0
- data/lib/ruby_llm/mcp/client.rb +54 -9
- data/lib/ruby_llm/mcp/errors.rb +7 -1
- data/lib/ruby_llm/mcp/parameter.rb +9 -0
- data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +30 -0
- data/lib/ruby_llm/mcp/providers/open_ai/complex_parameter_support.rb +27 -0
- data/lib/ruby_llm/mcp/requests/completion.rb +50 -0
- data/lib/ruby_llm/mcp/requests/initialization.rb +3 -7
- data/lib/ruby_llm/mcp/requests/notification.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resource_list.rb +21 -0
- data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +21 -0
- data/lib/ruby_llm/mcp/resource.rb +70 -0
- data/lib/ruby_llm/mcp/tool.rb +8 -16
- data/lib/ruby_llm/mcp/transport/sse.rb +47 -15
- data/lib/ruby_llm/mcp/transport/stdio.rb +17 -30
- data/lib/ruby_llm/mcp/transport/streamable.rb +274 -4
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +8 -1
- metadata +14 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b6cf22dd6e681b51686e73f0149f91b0fd3a256ccbba1e3dd20461d0a92b370d
|
4
|
+
data.tar.gz: 8ed938f20b001c4879a409ab77944e6d8b5e53aa0062d6e499d3739355c5054d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 817fa242ef9d4cd526ea8905093d6c548f36ffeb228ee3b0acfbb0fd80303ec69557493e9b0ee0b2419c0d5adddf79c2bda02ba577e7e90a0503fd9482444bc5
|
7
|
+
data.tar.gz: '09326061dfcdc66d963ef55740ad4d0d82c260ed1da6021229d6441c9c8f1c17af744fd90d9e865e9ddc209589f663c8c5a94aea020ca7bc189c41b668d58433'
|
data/README.md
CHANGED
@@ -8,7 +8,7 @@ This project is a Ruby client for the [Model Context Protocol (MCP)](https://mod
|
|
8
8
|
|
9
9
|
## Features
|
10
10
|
|
11
|
-
- 🔌 **Multiple Transport Types**: Support for SSE (Server-Sent Events) and stdio transports
|
11
|
+
- 🔌 **Multiple Transport Types**: Support for SSE (Server-Sent Events), Streamable HTTP, and stdio transports
|
12
12
|
- 🛠️ **Tool Integration**: Automatically converts MCP tools into RubyLLM-compatible tools
|
13
13
|
- 🔄 **Real-time Communication**: Efficient bidirectional communication with MCP servers
|
14
14
|
- 🎯 **Simple API**: Easy-to-use interface that integrates seamlessly with RubyLLM
|
@@ -66,6 +66,16 @@ client = RubyLLM::MCP.client(
|
|
66
66
|
env: { "NODE_ENV" => "production" }
|
67
67
|
}
|
68
68
|
)
|
69
|
+
|
70
|
+
# Or connect via streamable HTTP
|
71
|
+
client = RubyLLM::MCP.client(
|
72
|
+
name: "my-mcp-server",
|
73
|
+
transport_type: :streamable",
|
74
|
+
config: {
|
75
|
+
url: "http://localhost:8080/mcp",
|
76
|
+
headers: { "Authorization" => "Bearer your-token" }
|
77
|
+
}
|
78
|
+
)
|
69
79
|
```
|
70
80
|
|
71
81
|
### Using MCP Tools with RubyLLM
|
@@ -87,6 +97,14 @@ response = chat.ask("Can you help me search for recent files in my project?")
|
|
87
97
|
puts response
|
88
98
|
```
|
89
99
|
|
100
|
+
### Support Complex Parameters
|
101
|
+
|
102
|
+
If you want to support complex parameters, like an array of objects it currently requires a patch to RubyLLM itself. This is planned to be temporary until the RubyLLM is updated.
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
RubyLLM::MCP.support_complex_parameters!
|
106
|
+
```
|
107
|
+
|
90
108
|
### Streaming Responses with Tool Calls
|
91
109
|
|
92
110
|
```ruby
|
@@ -130,13 +148,28 @@ Best for web-based MCP servers or when you need HTTP-based communication:
|
|
130
148
|
```ruby
|
131
149
|
client = RubyLLM::MCP.client(
|
132
150
|
name: "web-mcp-server",
|
133
|
-
transport_type:
|
151
|
+
transport_type: :sse,
|
134
152
|
config: {
|
135
153
|
url: "https://your-mcp-server.com/mcp/sse"
|
136
154
|
}
|
137
155
|
)
|
138
156
|
```
|
139
157
|
|
158
|
+
### Streamable HTTP
|
159
|
+
|
160
|
+
Best for HTTP-based MCP servers that support streaming responses:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
client = RubyLLM::MCP.client(
|
164
|
+
name: "streaming-mcp-server",
|
165
|
+
transport_type: :streamable,
|
166
|
+
config: {
|
167
|
+
url: "https://your-mcp-server.com/mcp",
|
168
|
+
headers: { "Authorization" => "Bearer your-token" }
|
169
|
+
}
|
170
|
+
)
|
171
|
+
```
|
172
|
+
|
140
173
|
### Stdio
|
141
174
|
|
142
175
|
Best for local MCP servers or command-line tools:
|
@@ -144,7 +177,7 @@ Best for local MCP servers or command-line tools:
|
|
144
177
|
```ruby
|
145
178
|
client = RubyLLM::MCP.client(
|
146
179
|
name: "local-mcp-server",
|
147
|
-
transport_type:
|
180
|
+
transport_type: :stdio,
|
148
181
|
config: {
|
149
182
|
command: "python",
|
150
183
|
args: ["-m", "my_mcp_server"],
|
@@ -156,10 +189,11 @@ client = RubyLLM::MCP.client(
|
|
156
189
|
## Configuration Options
|
157
190
|
|
158
191
|
- `name`: A unique identifier for your MCP client
|
159
|
-
- `transport_type`: Either `:sse
|
192
|
+
- `transport_type`: Either `:sse`, `:streamable`, or `:stdio`
|
160
193
|
- `request_timeout`: Timeout for requests in milliseconds (default: 8000)
|
161
194
|
- `config`: Transport-specific configuration
|
162
195
|
- For SSE: `{ url: "http://..." }`
|
196
|
+
- For Streamable: `{ url: "http://...", headers: {...} }`
|
163
197
|
- For stdio: `{ command: "...", args: [...], env: {...} }`
|
164
198
|
|
165
199
|
## Development
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
class Capabilities
|
6
|
+
attr_reader :capabilities
|
7
|
+
|
8
|
+
def initialize(capabilities)
|
9
|
+
@capabilities = capabilities
|
10
|
+
end
|
11
|
+
|
12
|
+
def resources_list_changed?
|
13
|
+
@capabilities.dig("resources", "listChanged") || false
|
14
|
+
end
|
15
|
+
|
16
|
+
def resource_subscribe?
|
17
|
+
@capabilities.dig("resources", "subscribe") || false
|
18
|
+
end
|
19
|
+
|
20
|
+
def tools_list_changed?
|
21
|
+
@capabilities.dig("tools", "listChanged") || false
|
22
|
+
end
|
23
|
+
|
24
|
+
def completion?
|
25
|
+
@capabilities["completion"].present?
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/ruby_llm/mcp/client.rb
CHANGED
@@ -4,21 +4,30 @@ module RubyLLM
|
|
4
4
|
module MCP
|
5
5
|
class Client
|
6
6
|
PROTOCOL_VERSION = "2025-03-26"
|
7
|
+
PV_2024_11_05 = "2024-11-05"
|
8
|
+
|
9
|
+
attr_reader :name, :config, :transport_type, :transport, :request_timeout, :reverse_proxy_url, :protocol_version,
|
10
|
+
:capabilities
|
7
11
|
|
8
12
|
def initialize(name:, transport_type:, request_timeout: 8000, reverse_proxy_url: nil, config: {})
|
9
13
|
@name = name
|
10
14
|
@config = config
|
15
|
+
@protocol_version = PROTOCOL_VERSION
|
16
|
+
@headers = config[:headers] || {}
|
17
|
+
|
11
18
|
@transport_type = transport_type.to_sym
|
12
19
|
|
13
|
-
# TODO: Add streamable HTTP
|
14
20
|
case @transport_type
|
15
21
|
when :sse
|
16
|
-
@transport = RubyLLM::MCP::Transport::SSE.new(config[:url])
|
22
|
+
@transport = RubyLLM::MCP::Transport::SSE.new(@config[:url], headers: @headers)
|
17
23
|
when :stdio
|
18
|
-
@transport = RubyLLM::MCP::Transport::Stdio.new(config[:command], args: config[:args], env: config[:env])
|
24
|
+
@transport = RubyLLM::MCP::Transport::Stdio.new(@config[:command], args: @config[:args], env: @config[:env])
|
25
|
+
when :streamable
|
26
|
+
@transport = RubyLLM::MCP::Transport::Streamable.new(@config[:url], headers: @headers)
|
19
27
|
else
|
20
28
|
raise "Invalid transport type: #{transport_type}"
|
21
29
|
end
|
30
|
+
@capabilities = nil
|
22
31
|
|
23
32
|
@request_timeout = request_timeout
|
24
33
|
@reverse_proxy_url = reverse_proxy_url
|
@@ -27,8 +36,8 @@ module RubyLLM
|
|
27
36
|
notification_request
|
28
37
|
end
|
29
38
|
|
30
|
-
def request(body,
|
31
|
-
@transport.request(body,
|
39
|
+
def request(body, **options)
|
40
|
+
@transport.request(body, **options)
|
32
41
|
end
|
33
42
|
|
34
43
|
def tools(refresh: false)
|
@@ -36,6 +45,16 @@ module RubyLLM
|
|
36
45
|
@tools ||= fetch_and_create_tools
|
37
46
|
end
|
38
47
|
|
48
|
+
def resources(refresh: false)
|
49
|
+
@resources = nil if refresh
|
50
|
+
@resources ||= fetch_and_create_resources
|
51
|
+
end
|
52
|
+
|
53
|
+
def resource_templates(refresh: false)
|
54
|
+
@resource_templates = nil if refresh
|
55
|
+
@resource_templates ||= fetch_and_create_resources(set_as_template: true)
|
56
|
+
end
|
57
|
+
|
39
58
|
def execute_tool(name:, parameters:)
|
40
59
|
response = execute_tool_request(name: name, parameters: parameters)
|
41
60
|
result = response["result"]
|
@@ -45,22 +64,39 @@ module RubyLLM
|
|
45
64
|
result["content"].map { |content| content["text"] }.join("\n")
|
46
65
|
end
|
47
66
|
|
67
|
+
def resource_read_request(**args)
|
68
|
+
RubyLLM::MCP::Requests::ResourceRead.new(self, **args).call
|
69
|
+
end
|
70
|
+
|
71
|
+
def completion(**args)
|
72
|
+
RubyLLM::MCP::Requests::Completion.new(self, **args).call
|
73
|
+
end
|
74
|
+
|
48
75
|
private
|
49
76
|
|
50
77
|
def initialize_request
|
51
78
|
@initialize_response = RubyLLM::MCP::Requests::Initialization.new(self).call
|
79
|
+
@capabilities = RubyLLM::MCP::Capabilities.new(@initialize_response["result"]["capabilities"])
|
52
80
|
end
|
53
81
|
|
54
82
|
def notification_request
|
55
|
-
|
83
|
+
RubyLLM::MCP::Requests::Notification.new(self).call
|
56
84
|
end
|
57
85
|
|
58
86
|
def tool_list_request
|
59
|
-
|
87
|
+
RubyLLM::MCP::Requests::ToolList.new(self).call
|
88
|
+
end
|
89
|
+
|
90
|
+
def execute_tool_request(**args)
|
91
|
+
RubyLLM::MCP::Requests::ToolCall.new(self, **args).call
|
92
|
+
end
|
93
|
+
|
94
|
+
def resources_list_request
|
95
|
+
RubyLLM::MCP::Requests::ResourceList.new(self).call
|
60
96
|
end
|
61
97
|
|
62
|
-
def
|
63
|
-
|
98
|
+
def resource_template_list_request
|
99
|
+
RubyLLM::MCP::Requests::ResourceTemplateList.new(self).call
|
64
100
|
end
|
65
101
|
|
66
102
|
def fetch_and_create_tools
|
@@ -71,6 +107,15 @@ module RubyLLM
|
|
71
107
|
RubyLLM::MCP::Tool.new(self, tool)
|
72
108
|
end
|
73
109
|
end
|
110
|
+
|
111
|
+
def fetch_and_create_resources(set_as_template: false)
|
112
|
+
resources_response = resources_list_request
|
113
|
+
resources_response = resources_response["result"]["resources"]
|
114
|
+
|
115
|
+
@resources = resources_response.map do |resource|
|
116
|
+
RubyLLM::MCP::Resource.new(self, resource, template: set_as_template)
|
117
|
+
end
|
118
|
+
end
|
74
119
|
end
|
75
120
|
end
|
76
121
|
end
|
data/lib/ruby_llm/mcp/errors.rb
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
module RubyLLM
|
4
4
|
module MCP
|
5
5
|
module Errors
|
6
|
-
class
|
6
|
+
class BaseError < StandardError
|
7
7
|
attr_reader :message
|
8
8
|
|
9
9
|
def initialize(message:)
|
@@ -11,6 +11,12 @@ module RubyLLM
|
|
11
11
|
super(message)
|
12
12
|
end
|
13
13
|
end
|
14
|
+
|
15
|
+
class InvalidProtocolVersionError < BaseError; end
|
16
|
+
|
17
|
+
class SessionExpiredError < BaseError; end
|
18
|
+
|
19
|
+
class TimeoutError < BaseError; end
|
14
20
|
end
|
15
21
|
end
|
16
22
|
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Providers
|
6
|
+
module Anthropic
|
7
|
+
module ComplexParameterSupport
|
8
|
+
def clean_parameters(parameters)
|
9
|
+
parameters.transform_values do |param|
|
10
|
+
format = {
|
11
|
+
type: param.type,
|
12
|
+
description: param.description
|
13
|
+
}.compact
|
14
|
+
|
15
|
+
if param.type == "array"
|
16
|
+
format[:items] = param.items
|
17
|
+
elsif param.type == "object"
|
18
|
+
format[:properties] = clean_parameters(param.properties)
|
19
|
+
end
|
20
|
+
|
21
|
+
format
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
RubyLLM::Providers::Anthropic.extend(RubyLLM::MCP::Providers::Anthropic::ComplexParameterSupport)
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Providers
|
6
|
+
module OpenAI
|
7
|
+
module ComplexParameterSupport
|
8
|
+
def param_schema(param)
|
9
|
+
format = {
|
10
|
+
type: param.type,
|
11
|
+
description: param.description
|
12
|
+
}.compact
|
13
|
+
|
14
|
+
if param.type == "array"
|
15
|
+
format[:items] = param.items
|
16
|
+
elsif param.type == "object"
|
17
|
+
format[:properties] = param.properties.transform_values { |value| param_schema(value) }
|
18
|
+
end
|
19
|
+
format
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
RubyLLM::Providers::OpenAI.extend(RubyLLM::MCP::Providers::OpenAI::ComplexParameterSupport)
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class Completion
|
7
|
+
def initialize(client, type:, name:, argument:, value:)
|
8
|
+
@client = client
|
9
|
+
@type = type
|
10
|
+
@name = name
|
11
|
+
@argument = argument
|
12
|
+
@value = value
|
13
|
+
end
|
14
|
+
|
15
|
+
def call
|
16
|
+
@client.request(request_body)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def request_body
|
22
|
+
{
|
23
|
+
jsonrpc: "2.0",
|
24
|
+
id: 1,
|
25
|
+
method: "completion/complete",
|
26
|
+
params: {
|
27
|
+
ref: {
|
28
|
+
type: ref_type,
|
29
|
+
name: @name
|
30
|
+
},
|
31
|
+
argument: {
|
32
|
+
name: @argument,
|
33
|
+
value: @value
|
34
|
+
}
|
35
|
+
}
|
36
|
+
}
|
37
|
+
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
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -12,14 +12,10 @@ class RubyLLM::MCP::Requests::Initialization < RubyLLM::MCP::Requests::Base
|
|
12
12
|
jsonrpc: "2.0",
|
13
13
|
method: "initialize",
|
14
14
|
params: {
|
15
|
-
protocolVersion:
|
16
|
-
capabilities: {
|
17
|
-
tools: {
|
18
|
-
listChanged: true
|
19
|
-
}
|
20
|
-
},
|
15
|
+
protocolVersion: @client.protocol_version,
|
16
|
+
capabilities: {},
|
21
17
|
clientInfo: {
|
22
|
-
name: "RubyLLM
|
18
|
+
name: "RubyLLM-MCP Client",
|
23
19
|
version: RubyLLM::MCP::VERSION
|
24
20
|
}
|
25
21
|
}
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceList < Base
|
7
|
+
def call
|
8
|
+
client.request(resource_list_body)
|
9
|
+
end
|
10
|
+
|
11
|
+
def resource_list_body
|
12
|
+
{
|
13
|
+
jsonrpc: "2.0",
|
14
|
+
method: "resources/list",
|
15
|
+
params: {}
|
16
|
+
}
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceRead
|
7
|
+
attr_reader :client, :uri
|
8
|
+
|
9
|
+
def initialize(client, uri:)
|
10
|
+
@client = client
|
11
|
+
@uri = uri
|
12
|
+
end
|
13
|
+
|
14
|
+
def call
|
15
|
+
client.request(reading_resource_body(uri))
|
16
|
+
end
|
17
|
+
|
18
|
+
def reading_resource_body(uri)
|
19
|
+
{
|
20
|
+
jsonrpc: "2.0",
|
21
|
+
method: "resources/read",
|
22
|
+
params: {
|
23
|
+
uri: uri
|
24
|
+
}
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceTemplateList < Base
|
7
|
+
def call
|
8
|
+
client.request(resource_template_list_body)
|
9
|
+
end
|
10
|
+
|
11
|
+
def resource_template_list_body
|
12
|
+
{
|
13
|
+
jsonrpc: "2.0",
|
14
|
+
method: "resources/templates/list",
|
15
|
+
params: {}
|
16
|
+
}
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
class Resource
|
6
|
+
attr_reader :uri, :name, :description, :mime_type, :mcp_client, :template
|
7
|
+
|
8
|
+
def initialize(mcp_client, resource, template: false)
|
9
|
+
@mcp_client = mcp_client
|
10
|
+
@uri = resource["uri"]
|
11
|
+
@name = resource["name"]
|
12
|
+
@description = resource["description"]
|
13
|
+
@mime_type = resource["mimeType"]
|
14
|
+
@template = template
|
15
|
+
end
|
16
|
+
|
17
|
+
def content(parsable_information: {})
|
18
|
+
response = if template?
|
19
|
+
templated_uri = apply_template(@uri, parsable_information)
|
20
|
+
|
21
|
+
read_response(uri: templated_uri)
|
22
|
+
else
|
23
|
+
read_response
|
24
|
+
end
|
25
|
+
|
26
|
+
response.dig("result", "contents", 0, "text") ||
|
27
|
+
response.dig("result", "contents", 0, "blob")
|
28
|
+
end
|
29
|
+
|
30
|
+
def arguments_search(argument, value)
|
31
|
+
if template? && @mcp_client.capabilities.completion?
|
32
|
+
response = @mcp_client.completion(type: :resource, name: @name, argument: argument, value: value)
|
33
|
+
response = response.dig("result", "completion")
|
34
|
+
|
35
|
+
Struct.new(:arg_values, :total, :has_more)
|
36
|
+
.new(response["values"], response["total"], response["hasMore"])
|
37
|
+
else
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def template?
|
43
|
+
@template
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def read_response(uri: @uri)
|
49
|
+
parsed = URI.parse(uri)
|
50
|
+
case parsed.scheme
|
51
|
+
when "http", "https"
|
52
|
+
fetch_uri_content(uri)
|
53
|
+
else # file:// or git://
|
54
|
+
@read_response ||= @mcp_client.resource_read_request(uri: uri)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def fetch_uri_content(uri)
|
59
|
+
response = Faraday.get(uri)
|
60
|
+
{ "result" => { "contents" => [{ "text" => response.body }] } }
|
61
|
+
end
|
62
|
+
|
63
|
+
def apply_template(uri, parsable_information)
|
64
|
+
uri.gsub(/\{(\w+)\}/) do
|
65
|
+
parsable_information[::Regexp.last_match(1).to_sym] || "{#{::Regexp.last_match(1)}}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/ruby_llm/mcp/tool.rb
CHANGED
@@ -5,21 +5,6 @@ module RubyLLM
|
|
5
5
|
class Tool < RubyLLM::Tool
|
6
6
|
attr_reader :name, :description, :parameters, :mcp_client, :tool_response
|
7
7
|
|
8
|
-
# @tool_response = {
|
9
|
-
# name: string; // Unique identifier for the tool
|
10
|
-
# description?: string; // Human-readable description
|
11
|
-
# inputSchema: { // JSON Schema for the tool's parameters
|
12
|
-
# type: "object",
|
13
|
-
# properties: { ... } // Tool-specific parameters
|
14
|
-
# },
|
15
|
-
# annotations?: { // Optional hints about tool behavior
|
16
|
-
# title?: string; // Human-readable title for the tool
|
17
|
-
# readOnlyHint?: boolean; // If true, the tool does not modify its environment
|
18
|
-
# destructiveHint?: boolean; // If true, the tool may perform destructive updates
|
19
|
-
# idempotentHint?: boolean; // If true, repeated calls with same args have no additional effect
|
20
|
-
# openWorldHint?: boolean; // If true, tool interacts with external entities
|
21
|
-
# }
|
22
|
-
# }
|
23
8
|
def initialize(mcp_client, tool_response)
|
24
9
|
super()
|
25
10
|
@mcp_client = mcp_client
|
@@ -41,13 +26,20 @@ module RubyLLM
|
|
41
26
|
def create_parameters(input_schema)
|
42
27
|
params = {}
|
43
28
|
input_schema["properties"].each_key do |key|
|
44
|
-
param = RubyLLM::Parameter.new(
|
29
|
+
param = RubyLLM::MCP::Parameter.new(
|
45
30
|
key,
|
46
31
|
type: input_schema["properties"][key]["type"],
|
47
32
|
desc: input_schema["properties"][key]["description"],
|
48
33
|
required: input_schema["properties"][key]["required"]
|
49
34
|
)
|
50
35
|
|
36
|
+
if param.type == "array"
|
37
|
+
param.items = input_schema["properties"][key]["items"]
|
38
|
+
elsif param.type == "object"
|
39
|
+
properties = create_parameters(input_schema["properties"][key]["properties"])
|
40
|
+
param.properties = properties
|
41
|
+
end
|
42
|
+
|
51
43
|
params[key] = param
|
52
44
|
end
|
53
45
|
|
@@ -14,7 +14,12 @@ module RubyLLM
|
|
14
14
|
|
15
15
|
def initialize(url, headers: {})
|
16
16
|
@event_url = url
|
17
|
-
@messages_url =
|
17
|
+
@messages_url = nil
|
18
|
+
|
19
|
+
uri = URI.parse(url)
|
20
|
+
@root_url = "#{uri.scheme}://#{uri.host}"
|
21
|
+
@root_url += ":#{uri.port}" if uri.port != uri.default_port
|
22
|
+
|
18
23
|
@client_id = SecureRandom.uuid
|
19
24
|
@headers = headers.merge({
|
20
25
|
"Accept" => "text/event-stream",
|
@@ -35,11 +40,14 @@ module RubyLLM
|
|
35
40
|
start_sse_listener
|
36
41
|
end
|
37
42
|
|
38
|
-
|
43
|
+
# rubocop:disable Metrics/MethodLength
|
44
|
+
def request(body, add_id: true, wait_for_response: true)
|
39
45
|
# Generate a unique request ID
|
40
|
-
|
41
|
-
|
42
|
-
|
46
|
+
if add_id
|
47
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
48
|
+
request_id = @id_counter
|
49
|
+
body["id"] = request_id
|
50
|
+
end
|
43
51
|
|
44
52
|
# Create a queue for this request's response
|
45
53
|
response_queue = Queue.new
|
@@ -83,6 +91,7 @@ module RubyLLM
|
|
83
91
|
raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
|
84
92
|
end
|
85
93
|
end
|
94
|
+
# rubocop:enable Metrics/MethodLength
|
86
95
|
|
87
96
|
def close
|
88
97
|
@running = false
|
@@ -96,17 +105,35 @@ module RubyLLM
|
|
96
105
|
@connection_mutex.synchronize do
|
97
106
|
return if sse_thread_running?
|
98
107
|
|
108
|
+
response_queue = Queue.new
|
109
|
+
@pending_mutex.synchronize do
|
110
|
+
@pending_requests["endpoint"] = response_queue
|
111
|
+
end
|
112
|
+
|
99
113
|
@sse_thread = Thread.new do
|
100
114
|
listen_for_events while @running
|
101
115
|
end
|
102
|
-
|
103
116
|
@sse_thread.abort_on_exception = true
|
104
|
-
|
117
|
+
|
118
|
+
endpoint = response_queue.pop
|
119
|
+
set_message_endpoint(endpoint)
|
120
|
+
|
121
|
+
@pending_mutex.synchronize { @pending_requests.delete("endpoint") }
|
105
122
|
end
|
106
123
|
end
|
107
124
|
|
125
|
+
def set_message_endpoint(endpoint)
|
126
|
+
uri = URI.parse(endpoint)
|
127
|
+
|
128
|
+
@messages_url = if uri.host.nil?
|
129
|
+
"#{@root_url}#{endpoint}"
|
130
|
+
else
|
131
|
+
endpoint
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
108
135
|
def sse_thread_running?
|
109
|
-
@sse_thread
|
136
|
+
@sse_thread&.alive?
|
110
137
|
end
|
111
138
|
|
112
139
|
def listen_for_events
|
@@ -160,14 +187,19 @@ module RubyLLM
|
|
160
187
|
def process_event(raw_event)
|
161
188
|
return if raw_event[:data].nil?
|
162
189
|
|
163
|
-
event
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
190
|
+
if raw_event[:event] == "endpoint"
|
191
|
+
request_id = "endpoint"
|
192
|
+
event = raw_event[:data]
|
193
|
+
else
|
194
|
+
event = begin
|
195
|
+
JSON.parse(raw_event[:data])
|
196
|
+
rescue StandardError
|
197
|
+
nil
|
198
|
+
end
|
199
|
+
return if event.nil?
|
169
200
|
|
170
|
-
|
201
|
+
request_id = event["id"]&.to_s
|
202
|
+
end
|
171
203
|
|
172
204
|
@pending_mutex.synchronize do
|
173
205
|
if request_id && @pending_requests.key?(request_id)
|
@@ -14,10 +14,9 @@ module RubyLLM
|
|
14
14
|
def initialize(command, args: [], env: {})
|
15
15
|
@command = command
|
16
16
|
@args = args
|
17
|
-
@env = env
|
17
|
+
@env = env || {}
|
18
18
|
@client_id = SecureRandom.uuid
|
19
19
|
|
20
|
-
# Initialize state variables
|
21
20
|
@id_counter = 0
|
22
21
|
@id_mutex = Mutex.new
|
23
22
|
@pending_requests = {}
|
@@ -25,17 +24,16 @@ module RubyLLM
|
|
25
24
|
@running = true
|
26
25
|
@reader_thread = nil
|
27
26
|
|
28
|
-
# Start the process
|
29
27
|
start_process
|
30
28
|
end
|
31
29
|
|
32
|
-
def request(body, wait_for_response: true)
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
30
|
+
def request(body, add_id: true, wait_for_response: true)
|
31
|
+
if add_id
|
32
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
33
|
+
request_id = @id_counter
|
34
|
+
body["id"] = request_id
|
35
|
+
end
|
37
36
|
|
38
|
-
# Create a queue for this request's response
|
39
37
|
response_queue = Queue.new
|
40
38
|
if wait_for_response
|
41
39
|
@pending_mutex.synchronize do
|
@@ -43,7 +41,6 @@ module RubyLLM
|
|
43
41
|
end
|
44
42
|
end
|
45
43
|
|
46
|
-
# Send the request to the process
|
47
44
|
begin
|
48
45
|
@stdin.puts(JSON.generate(body))
|
49
46
|
@stdin.flush
|
@@ -55,7 +52,6 @@ module RubyLLM
|
|
55
52
|
|
56
53
|
return unless wait_for_response
|
57
54
|
|
58
|
-
# Wait for the response with matching ID using a timeout
|
59
55
|
begin
|
60
56
|
Timeout.timeout(30) do
|
61
57
|
response_queue.pop
|
@@ -69,21 +65,18 @@ module RubyLLM
|
|
69
65
|
def close
|
70
66
|
@running = false
|
71
67
|
|
72
|
-
# Close stdin to signal the process to exit
|
73
68
|
begin
|
74
69
|
@stdin&.close
|
75
70
|
rescue StandardError
|
76
71
|
nil
|
77
72
|
end
|
78
73
|
|
79
|
-
# Wait for process to exit
|
80
74
|
begin
|
81
75
|
@wait_thread&.join(1)
|
82
76
|
rescue StandardError
|
83
77
|
nil
|
84
78
|
end
|
85
79
|
|
86
|
-
# Close remaining IO streams
|
87
80
|
begin
|
88
81
|
@stdout&.close
|
89
82
|
rescue StandardError
|
@@ -95,7 +88,6 @@ module RubyLLM
|
|
95
88
|
nil
|
96
89
|
end
|
97
90
|
|
98
|
-
# Wait for reader thread to finish
|
99
91
|
begin
|
100
92
|
@reader_thread&.join(1)
|
101
93
|
rescue StandardError
|
@@ -112,13 +104,14 @@ module RubyLLM
|
|
112
104
|
private
|
113
105
|
|
114
106
|
def start_process
|
115
|
-
# Close any existing process
|
116
107
|
close if @stdin || @stdout || @stderr || @wait_thread
|
117
108
|
|
118
|
-
|
119
|
-
|
109
|
+
@stdin, @stdout, @stderr, @wait_thread = if @env.empty?
|
110
|
+
Open3.popen3(@command, *@args)
|
111
|
+
else
|
112
|
+
Open3.popen3(environment_string, @command, *@args)
|
113
|
+
end
|
120
114
|
|
121
|
-
# Start a thread to read responses
|
122
115
|
start_reader_thread
|
123
116
|
end
|
124
117
|
|
@@ -137,13 +130,9 @@ module RubyLLM
|
|
137
130
|
next
|
138
131
|
end
|
139
132
|
|
140
|
-
# Read a line from the process
|
141
133
|
line = @stdout.gets
|
142
|
-
|
143
|
-
# Skip empty lines
|
144
134
|
next unless line && !line.strip.empty?
|
145
135
|
|
146
|
-
# Process the response
|
147
136
|
process_response(line.strip)
|
148
137
|
rescue IOError, Errno::EPIPE => e
|
149
138
|
puts "Reader error: #{e.message}. Restarting in 1 second..."
|
@@ -160,19 +149,13 @@ module RubyLLM
|
|
160
149
|
end
|
161
150
|
|
162
151
|
def process_response(line)
|
163
|
-
# Try to parse the response as JSON
|
164
152
|
response = begin
|
165
153
|
JSON.parse(line)
|
166
154
|
rescue JSON::ParserError => e
|
167
|
-
|
168
|
-
puts "Raw response: #{line}"
|
169
|
-
return
|
155
|
+
raise "Error parsing response as JSON: #{e.message}\nRaw response: #{line}"
|
170
156
|
end
|
171
|
-
|
172
|
-
# Extract the request ID
|
173
157
|
request_id = response["id"]&.to_s
|
174
158
|
|
175
|
-
# Find and fulfill the matching request
|
176
159
|
@pending_mutex.synchronize do
|
177
160
|
if request_id && @pending_requests.key?(request_id)
|
178
161
|
response_queue = @pending_requests.delete(request_id)
|
@@ -180,6 +163,10 @@ module RubyLLM
|
|
180
163
|
end
|
181
164
|
end
|
182
165
|
end
|
166
|
+
|
167
|
+
def environment_string
|
168
|
+
@env.map { |key, value| "#{key}=#{value}" }.join(" ")
|
169
|
+
end
|
183
170
|
end
|
184
171
|
end
|
185
172
|
end
|
@@ -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
|
-
@
|
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(
|
13
|
-
#
|
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
|
-
|
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
|
data/lib/ruby_llm/mcp/version.rb
CHANGED
data/lib/ruby_llm/mcp.rb
CHANGED
@@ -10,8 +10,15 @@ loader.setup
|
|
10
10
|
|
11
11
|
module RubyLLM
|
12
12
|
module MCP
|
13
|
-
|
13
|
+
module_function
|
14
|
+
|
15
|
+
def client(*args, **kwargs)
|
14
16
|
@client ||= Client.new(*args, **kwargs)
|
15
17
|
end
|
18
|
+
|
19
|
+
def support_complex_parameters!
|
20
|
+
require_relative "mcp/providers/open_ai/complex_parameter_support"
|
21
|
+
require_relative "mcp/providers/anthropic/complex_parameter_support"
|
22
|
+
end
|
16
23
|
end
|
17
24
|
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
|
4
|
+
version: 0.1.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-
|
11
|
+
date: 2025-06-11 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.
|
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.
|
82
|
+
version: '1.3'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
84
|
name: zeitwerk
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -108,13 +108,22 @@ files:
|
|
108
108
|
- LICENSE
|
109
109
|
- README.md
|
110
110
|
- lib/ruby_llm/mcp.rb
|
111
|
+
- lib/ruby_llm/mcp/capabilities.rb
|
111
112
|
- lib/ruby_llm/mcp/client.rb
|
112
113
|
- lib/ruby_llm/mcp/errors.rb
|
114
|
+
- lib/ruby_llm/mcp/parameter.rb
|
115
|
+
- lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb
|
116
|
+
- lib/ruby_llm/mcp/providers/open_ai/complex_parameter_support.rb
|
113
117
|
- lib/ruby_llm/mcp/requests/base.rb
|
118
|
+
- lib/ruby_llm/mcp/requests/completion.rb
|
114
119
|
- lib/ruby_llm/mcp/requests/initialization.rb
|
115
120
|
- lib/ruby_llm/mcp/requests/notification.rb
|
121
|
+
- lib/ruby_llm/mcp/requests/resource_list.rb
|
122
|
+
- lib/ruby_llm/mcp/requests/resource_read.rb
|
123
|
+
- lib/ruby_llm/mcp/requests/resource_template_list.rb
|
116
124
|
- lib/ruby_llm/mcp/requests/tool_call.rb
|
117
125
|
- lib/ruby_llm/mcp/requests/tool_list.rb
|
126
|
+
- lib/ruby_llm/mcp/resource.rb
|
118
127
|
- lib/ruby_llm/mcp/tool.rb
|
119
128
|
- lib/ruby_llm/mcp/transport/sse.rb
|
120
129
|
- lib/ruby_llm/mcp/transport/stdio.rb
|
@@ -146,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
146
155
|
- !ruby/object:Gem::Version
|
147
156
|
version: '0'
|
148
157
|
requirements: []
|
149
|
-
rubygems_version: 3.
|
158
|
+
rubygems_version: 3.3.7
|
150
159
|
signing_key:
|
151
160
|
specification_version: 4
|
152
161
|
summary: A RubyLLM MCP Client
|