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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0de5a83b2962398c602fe95d3b1c606bf3c0d1b8367209b70f9ef1f41a7f1e2
4
- data.tar.gz: 65c1a8c158efc387d9bec8ee67b701432cc605fd276647496d0825d0d83cafb3
3
+ metadata.gz: b6cf22dd6e681b51686e73f0149f91b0fd3a256ccbba1e3dd20461d0a92b370d
4
+ data.tar.gz: 8ed938f20b001c4879a409ab77944e6d8b5e53aa0062d6e499d3739355c5054d
5
5
  SHA512:
6
- metadata.gz: 416161bdd8d22711c144dd7bf3460e27f01cd57f764ebc5d024748bcdcc4fcf279898da28fefcba1ae31546f8b001d48798c7f9e48a0c1bc7b1ad555e2334fcd
7
- data.tar.gz: f9a6cbabf0e78aa89268c0cbbdaa2e07e9b33497cf47d1eab7282f60c6bfa161e4d5cbb7a1e6694de93ed18988318c1040ee0e3ffcd363b5d2d761e40158ce1b
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: "sse",
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: "stdio",
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` or `:stdio`
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
@@ -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, wait_for_response: true)
31
- @transport.request(body, wait_for_response: wait_for_response)
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
- @notification_response = RubyLLM::MCP::Requests::Notification.new(self).call
83
+ RubyLLM::MCP::Requests::Notification.new(self).call
56
84
  end
57
85
 
58
86
  def tool_list_request
59
- @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
60
96
  end
61
97
 
62
- def execute_tool_request(name:, parameters:)
63
- @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
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
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Parameter < RubyLLM::Parameter
6
+ attr_accessor :items, :properties
7
+ end
8
+ end
9
+ 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: 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
@@ -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 = 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)
@@ -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
- # Generate a unique request ID
34
- @id_mutex.synchronize { @id_counter += 1 }
35
- request_id = @id_counter
36
- 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
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
- # Start a new process
119
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
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
- puts "Error parsing response as JSON: #{e.message}"
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
- @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.1"
5
+ VERSION = "0.1.0"
6
6
  end
7
7
  end
data/lib/ruby_llm/mcp.rb CHANGED
@@ -10,8 +10,15 @@ loader.setup
10
10
 
11
11
  module RubyLLM
12
12
  module MCP
13
- def self.client(*args, **kwargs)
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.1
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-22 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,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.5.11
158
+ rubygems_version: 3.3.7
150
159
  signing_key:
151
160
  specification_version: 4
152
161
  summary: A RubyLLM MCP Client