ruby_llm-mcp 0.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 131f358210388bfb593787a010a20459a2fc319f2249ee24594402ee92f78e5d
4
- data.tar.gz: dbe0431413c8986bc76987cf748227c8e74bc201cfed23635d487430924d7f0a
3
+ metadata.gz: b6cf22dd6e681b51686e73f0149f91b0fd3a256ccbba1e3dd20461d0a92b370d
4
+ data.tar.gz: 8ed938f20b001c4879a409ab77944e6d8b5e53aa0062d6e499d3739355c5054d
5
5
  SHA512:
6
- metadata.gz: f4d4a86d99b955925457348b7a1ec1916b42ed8afda1549c311f1915fa7a0c106f51350819968679970366f9bdeaa393cf42f239b6709c40558065c2f8328484
7
- data.tar.gz: a775771f2574f7a77627a1215bc60aaded76a287da65e13bd7504a0bb61a83f81494cddb5280e7cc960f989daef4149180a5e121d30d5da940488a0805183937
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
@@ -138,13 +148,28 @@ Best for web-based MCP servers or when you need HTTP-based communication:
138
148
  ```ruby
139
149
  client = RubyLLM::MCP.client(
140
150
  name: "web-mcp-server",
141
- transport_type: "sse",
151
+ transport_type: :sse,
142
152
  config: {
143
153
  url: "https://your-mcp-server.com/mcp/sse"
144
154
  }
145
155
  )
146
156
  ```
147
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
+
148
173
  ### Stdio
149
174
 
150
175
  Best for local MCP servers or command-line tools:
@@ -152,7 +177,7 @@ Best for local MCP servers or command-line tools:
152
177
  ```ruby
153
178
  client = RubyLLM::MCP.client(
154
179
  name: "local-mcp-server",
155
- transport_type: "stdio",
180
+ transport_type: :stdio,
156
181
  config: {
157
182
  command: "python",
158
183
  args: ["-m", "my_mcp_server"],
@@ -164,10 +189,11 @@ client = RubyLLM::MCP.client(
164
189
  ## Configuration Options
165
190
 
166
191
  - `name`: A unique identifier for your MCP client
167
- - `transport_type`: Either `:sse` or `:stdio`
192
+ - `transport_type`: Either `:sse`, `:streamable`, or `:stdio`
168
193
  - `request_timeout`: Timeout for requests in milliseconds (default: 8000)
169
194
  - `config`: Transport-specific configuration
170
195
  - For SSE: `{ url: "http://..." }`
196
+ - For Streamable: `{ url: "http://...", headers: {...} }`
171
197
  - For stdio: `{ command: "...", args: [...], env: {...} }`
172
198
 
173
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
@@ -4,22 +4,30 @@ module RubyLLM
4
4
  module MCP
5
5
  class Client
6
6
  PROTOCOL_VERSION = "2025-03-26"
7
- attr_reader :name, :config, :transport_type, :transport, :request_timeout, :reverse_proxy_url
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
8
11
 
9
12
  def initialize(name:, transport_type:, request_timeout: 8000, reverse_proxy_url: nil, config: {})
10
13
  @name = name
11
14
  @config = config
15
+ @protocol_version = PROTOCOL_VERSION
16
+ @headers = config[:headers] || {}
17
+
12
18
  @transport_type = transport_type.to_sym
13
19
 
14
- # TODO: Add streamable HTTP
15
20
  case @transport_type
16
21
  when :sse
17
- @transport = RubyLLM::MCP::Transport::SSE.new(@config[:url])
22
+ @transport = RubyLLM::MCP::Transport::SSE.new(@config[:url], headers: @headers)
18
23
  when :stdio
19
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)
20
27
  else
21
28
  raise "Invalid transport type: #{transport_type}"
22
29
  end
30
+ @capabilities = nil
23
31
 
24
32
  @request_timeout = request_timeout
25
33
  @reverse_proxy_url = reverse_proxy_url
@@ -28,8 +36,8 @@ module RubyLLM
28
36
  notification_request
29
37
  end
30
38
 
31
- def request(body, wait_for_response: true)
32
- @transport.request(body, wait_for_response: wait_for_response)
39
+ def request(body, **options)
40
+ @transport.request(body, **options)
33
41
  end
34
42
 
35
43
  def tools(refresh: false)
@@ -37,6 +45,16 @@ module RubyLLM
37
45
  @tools ||= fetch_and_create_tools
38
46
  end
39
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
+
40
58
  def execute_tool(name:, parameters:)
41
59
  response = execute_tool_request(name: name, parameters: parameters)
42
60
  result = response["result"]
@@ -46,22 +64,39 @@ module RubyLLM
46
64
  result["content"].map { |content| content["text"] }.join("\n")
47
65
  end
48
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
+
49
75
  private
50
76
 
51
77
  def initialize_request
52
78
  @initialize_response = RubyLLM::MCP::Requests::Initialization.new(self).call
79
+ @capabilities = RubyLLM::MCP::Capabilities.new(@initialize_response["result"]["capabilities"])
53
80
  end
54
81
 
55
82
  def notification_request
56
- @notification_response = RubyLLM::MCP::Requests::Notification.new(self).call
83
+ RubyLLM::MCP::Requests::Notification.new(self).call
57
84
  end
58
85
 
59
86
  def tool_list_request
60
- @tool_request = RubyLLM::MCP::Requests::ToolList.new(self).call
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
61
96
  end
62
97
 
63
- def execute_tool_request(name:, parameters:)
64
- @execute_tool_response = RubyLLM::MCP::Requests::ToolCall.new(self, name: name, parameters: parameters).call
98
+ def resource_template_list_request
99
+ RubyLLM::MCP::Requests::ResourceTemplateList.new(self).call
65
100
  end
66
101
 
67
102
  def fetch_and_create_tools
@@ -72,6 +107,15 @@ module RubyLLM
72
107
  RubyLLM::MCP::Tool.new(self, tool)
73
108
  end
74
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
75
119
  end
76
120
  end
77
121
  end
@@ -3,7 +3,7 @@
3
3
  module RubyLLM
4
4
  module MCP
5
5
  module Errors
6
- class TimeoutError < StandardError
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,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: RubyLLM::MCP::Client::PROTOCOL_VERSION,
16
- capabilities: {
17
- tools: {
18
- listChanged: true
19
- }
20
- },
15
+ protocolVersion: @client.protocol_version,
16
+ capabilities: {},
21
17
  clientInfo: {
22
- name: "RubyLLM MCP Client",
18
+ name: "RubyLLM-MCP Client",
23
19
  version: RubyLLM::MCP::VERSION
24
20
  }
25
21
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  class RubyLLM::MCP::Requests::Notification < RubyLLM::MCP::Requests::Base
4
4
  def call
5
- client.request(notification_body, wait_for_response: false)
5
+ client.request(notification_body, add_id: false, wait_for_response: false)
6
6
  end
7
7
 
8
8
  def notification_body
@@ -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
@@ -14,7 +14,12 @@ module RubyLLM
14
14
 
15
15
  def initialize(url, headers: {})
16
16
  @event_url = url
17
- @messages_url = url.gsub("sse", "messages")
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
- def request(body, wait_for_response: true)
43
+ # rubocop:disable Metrics/MethodLength
44
+ def request(body, add_id: true, wait_for_response: true)
39
45
  # Generate a unique request ID
40
- @id_mutex.synchronize { @id_counter += 1 }
41
- request_id = @id_counter
42
- body["id"] = request_id
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
- sleep 0.1 # Wait for the SSE connection to be established
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 && @sse_thread.alive?
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 = begin
164
- JSON.parse(raw_event[:data])
165
- rescue StandardError
166
- nil
167
- end
168
- return if event.nil?
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
- request_id = event["id"]&.to_s
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)
@@ -27,10 +27,12 @@ module RubyLLM
27
27
  start_process
28
28
  end
29
29
 
30
- def request(body, wait_for_response: true)
31
- @id_mutex.synchronize { @id_counter += 1 }
32
- request_id = @id_counter
33
- body["id"] = request_id
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
34
36
 
35
37
  response_queue = Queue.new
36
38
  if wait_for_response
@@ -150,9 +152,7 @@ module RubyLLM
150
152
  response = begin
151
153
  JSON.parse(line)
152
154
  rescue JSON::ParserError => e
153
- puts "Error parsing response as JSON: #{e.message}"
154
- puts "Raw response: #{line}"
155
- return
155
+ raise "Error parsing response as JSON: #{e.message}\nRaw response: #{line}"
156
156
  end
157
157
  request_id = response["id"]&.to_s
158
158
 
@@ -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
- @headers = headers
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(messages)
13
- # TODO: Implement streaming
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
- # TODO: Implement closing
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module MCP
5
- VERSION = "0.0.2"
5
+ VERSION = "0.1.0"
6
6
  end
7
7
  end
data/lib/ruby_llm/mcp.rb CHANGED
@@ -17,8 +17,8 @@ module RubyLLM
17
17
  end
18
18
 
19
19
  def support_complex_parameters!
20
- require_relative "providers/open_ai/complex_parameter_support"
21
- require_relative "providers/anthropic/complex_parameter_support"
20
+ require_relative "mcp/providers/open_ai/complex_parameter_support"
21
+ require_relative "mcp/providers/anthropic/complex_parameter_support"
22
22
  end
23
23
  end
24
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.2
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-05-24 00:00:00.000000000 Z
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.2'
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.2'
82
+ version: '1.3'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: zeitwerk
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -108,22 +108,27 @@ 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
113
114
  - lib/ruby_llm/mcp/parameter.rb
114
115
  - lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb
115
116
  - lib/ruby_llm/mcp/providers/open_ai/complex_parameter_support.rb
116
117
  - lib/ruby_llm/mcp/requests/base.rb
118
+ - lib/ruby_llm/mcp/requests/completion.rb
117
119
  - lib/ruby_llm/mcp/requests/initialization.rb
118
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
119
124
  - lib/ruby_llm/mcp/requests/tool_call.rb
120
125
  - lib/ruby_llm/mcp/requests/tool_list.rb
126
+ - lib/ruby_llm/mcp/resource.rb
121
127
  - lib/ruby_llm/mcp/tool.rb
122
128
  - lib/ruby_llm/mcp/transport/sse.rb
123
129
  - lib/ruby_llm/mcp/transport/stdio.rb
124
130
  - lib/ruby_llm/mcp/transport/streamable.rb
125
131
  - lib/ruby_llm/mcp/version.rb
126
- - lib/ruby_llm/overrides.rb
127
132
  homepage: https://github.com/patvice/ruby_llm-mcp
128
133
  licenses:
129
134
  - MIT
@@ -150,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
155
  - !ruby/object:Gem::Version
151
156
  version: '0'
152
157
  requirements: []
153
- rubygems_version: 3.5.11
158
+ rubygems_version: 3.3.7
154
159
  signing_key:
155
160
  specification_version: 4
156
161
  summary: A RubyLLM MCP Client
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module ToolsComplexParametersSupport
5
- def param_schema(param)
6
- format = {
7
- type: param.type,
8
- description: param.description
9
- }.compact
10
-
11
- if param.type == "array"
12
- format[:items] = param.items
13
- elsif param.type == "object"
14
- format[:properties] = param.properties
15
- end
16
- format
17
- end
18
- end
19
- end
20
-
21
- RubyLLM::Providers::OpenAI.extend(RubyLLM::ToolsComplexParametersSupport)