robot_lab 0.3.1 → 0.5.1

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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -41,16 +41,22 @@ dependencies:
41
41
  name: ruby_llm
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - "~>"
44
+ - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '2.0'
46
+ version: 2.1.1
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '3'
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
50
53
  requirements:
51
- - - "~>"
54
+ - - ">="
52
55
  - !ruby/object:Gem::Version
53
- version: '2.0'
56
+ version: 2.1.1
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '3'
54
60
  - !ruby/object:Gem::Dependency
55
61
  name: ruby_llm-providers-apfel
56
62
  requirement: !ruby/object:Gem::Requirement
@@ -149,34 +155,6 @@ dependencies:
149
155
  - - "~>"
150
156
  - !ruby/object:Gem::Version
151
157
  version: '0.2'
152
- - !ruby/object:Gem::Dependency
153
- name: async-http
154
- requirement: !ruby/object:Gem::Requirement
155
- requirements:
156
- - - "~>"
157
- - !ruby/object:Gem::Version
158
- version: '0.60'
159
- type: :runtime
160
- prerelease: false
161
- version_requirements: !ruby/object:Gem::Requirement
162
- requirements:
163
- - - "~>"
164
- - !ruby/object:Gem::Version
165
- version: '0.60'
166
- - !ruby/object:Gem::Dependency
167
- name: async-websocket
168
- requirement: !ruby/object:Gem::Requirement
169
- requirements:
170
- - - "~>"
171
- - !ruby/object:Gem::Version
172
- version: '0.30'
173
- type: :runtime
174
- prerelease: false
175
- version_requirements: !ruby/object:Gem::Requirement
176
- requirements:
177
- - - "~>"
178
- - !ruby/object:Gem::Version
179
- version: '0.30'
180
158
  description: |
181
159
  RobotLab is a Ruby framework for building and orchestrating multi-robot LLM workflows.
182
160
  Built on ruby_llm, it provides robots with template-based prompts, tools, and shared
@@ -214,15 +192,10 @@ files:
214
192
  - lib/robot_lab/hook_context.rb
215
193
  - lib/robot_lab/hook_registry.rb
216
194
  - lib/robot_lab/hooks.rb
217
- - lib/robot_lab/mcp/client.rb
218
- - lib/robot_lab/mcp/connection_poller.rb
195
+ - lib/robot_lab/mcp.rb
196
+ - lib/robot_lab/mcp/registry.rb
219
197
  - lib/robot_lab/mcp/server.rb
220
198
  - lib/robot_lab/mcp/server_discovery.rb
221
- - lib/robot_lab/mcp/transports/base.rb
222
- - lib/robot_lab/mcp/transports/sse.rb
223
- - lib/robot_lab/mcp/transports/stdio.rb
224
- - lib/robot_lab/mcp/transports/streamable_http.rb
225
- - lib/robot_lab/mcp/transports/websocket.rb
226
199
  - lib/robot_lab/memory.rb
227
200
  - lib/robot_lab/memory_change.rb
228
201
  - lib/robot_lab/message.rb
@@ -238,6 +211,7 @@ files:
238
211
  - lib/robot_lab/robot/mcp_management.rb
239
212
  - lib/robot_lab/robot/result_building.rb
240
213
  - lib/robot_lab/robot/template_rendering.rb
214
+ - lib/robot_lab/robot/tool_call_gating.rb
241
215
  - lib/robot_lab/robot_message.rb
242
216
  - lib/robot_lab/robot_result.rb
243
217
  - lib/robot_lab/run_config.rb
@@ -1,223 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- # MCP client for communicating with Model Context Protocol servers
6
- #
7
- # Uses actionmcp gem for MCP protocol implementation.
8
- # Supports multiple transport types: StdIO, SSE, WebSocket, HTTP.
9
- #
10
- # @example
11
- # client = Client.new(name: "neon", transport: { type: "ws", url: "ws://..." })
12
- # client.connect
13
- # tools = client.list_tools
14
- # result = client.call_tool("createBranch", { project_id: "abc" })
15
- #
16
- # :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every public operation must check it.
17
- class Client
18
- # @!attribute [r] server
19
- # @return [Server] the MCP server configuration
20
- # @!attribute [r] connected
21
- # @return [Boolean] whether currently connected
22
- attr_reader :server, :connected, :transport
23
-
24
- # Creates a new MCP Client instance.
25
- #
26
- # @param server_or_config [Server, Hash] the server or configuration hash
27
- # @param poller [MCP::ConnectionPoller, nil] optional shared IO.select poller
28
- # for multiplexing multiple stdio transports (opt-in, default nil = per-client blocking)
29
- # @raise [ArgumentError] if config is invalid
30
- def initialize(server_or_config, poller: nil)
31
- @server = case server_or_config
32
- when Server
33
- server_or_config
34
- when Hash
35
- Server.new(**server_or_config.transform_keys(&:to_sym))
36
- else
37
- raise ArgumentError, "Invalid server config"
38
- end
39
- @connected = false
40
- @transport = nil
41
- @request_id = 0
42
- @poller = poller
43
- end
44
-
45
- # Connect to the MCP server
46
- #
47
- # @return [self]
48
- #
49
- # :reek:TooManyStatements -- linear connect/register sequence with a best-effort rescue.
50
- def connect
51
- return self if @connected
52
-
53
- @transport = create_transport
54
- @transport.connect if @transport.respond_to?(:connect)
55
- @connected = true
56
-
57
- # Register with shared poller after the transport is connected
58
- @poller.register(self) if @poller
59
-
60
- self
61
- rescue StandardError => e
62
- RobotLab.config.logger.warn("MCP connection failed for #{@server.name}: #{e.message}")
63
- @connected = false
64
- self
65
- end
66
-
67
- # Disconnect from the server
68
- #
69
- # @return [self]
70
- #
71
- def disconnect
72
- return self unless @connected
73
-
74
- @poller.unregister(self) if @poller
75
- @transport.close if @transport.respond_to?(:close)
76
- @connected = false
77
- @transport = nil
78
-
79
- self
80
- end
81
-
82
- # List available tools from the server
83
- #
84
- # @return [Array<Hash>] Tool definitions
85
- #
86
- def list_tools
87
- ensure_connected!
88
- response = request(method: "tools/list")
89
- response[:tools] || []
90
- end
91
-
92
- # Call a tool on the server
93
- #
94
- # @param name [String] Tool name
95
- # @param arguments [Hash] Tool arguments
96
- # @return [Object] Tool result
97
- #
98
- def call_tool(name, arguments = {})
99
- ensure_connected!
100
- response = request(
101
- method: "tools/call",
102
- params: { name: name, arguments: arguments }
103
- )
104
- response[:content] || response
105
- end
106
-
107
- # List available resources
108
- #
109
- # @return [Array<Hash>]
110
- #
111
- def list_resources
112
- ensure_connected!
113
- response = request(method: "resources/list")
114
- response[:resources] || []
115
- end
116
-
117
- # Read a resource
118
- #
119
- # @param uri [String] Resource URI
120
- # @return [Object]
121
- #
122
- def read_resource(uri)
123
- ensure_connected!
124
- response = request(method: "resources/read", params: { uri: uri })
125
- response[:contents] || response
126
- end
127
-
128
- # List available prompts
129
- #
130
- # @return [Array<Hash>]
131
- #
132
- def list_prompts
133
- ensure_connected!
134
- response = request(method: "prompts/list")
135
- response[:prompts] || []
136
- end
137
-
138
- # Get a prompt
139
- #
140
- # @param name [String] Prompt name
141
- # @param arguments [Hash] Prompt arguments
142
- # @return [Hash]
143
- #
144
- def get_prompt(name, arguments = {})
145
- ensure_connected!
146
- request(method: "prompts/get", params: { name: name, arguments: arguments })
147
- end
148
-
149
- # Checks if the client is connected to the server.
150
- #
151
- # @return [Boolean]
152
- def connected?
153
- @connected
154
- end
155
-
156
- # Converts the client to a hash representation.
157
- #
158
- # @return [Hash]
159
- def to_h
160
- {
161
- server: @server.to_h,
162
- connected: @connected
163
- }
164
- end
165
-
166
- private
167
-
168
- def ensure_connected!
169
- raise MCPError, "Not connected to MCP server: #{@server.name}" unless @connected
170
- end
171
-
172
- def create_transport
173
- config = @server.transport.merge(timeout: @server.timeout)
174
-
175
- case @server.transport_type
176
- when "stdio"
177
- Transports::Stdio.new(config)
178
- when "ws", "websocket"
179
- Transports::WebSocket.new(config)
180
- when "sse"
181
- Transports::SSE.new(config)
182
- when "streamable-http", "http"
183
- Transports::StreamableHTTP.new(config)
184
- else
185
- raise MCPError, "Unsupported transport type: #{@server.transport_type}"
186
- end
187
- end
188
-
189
- def request(method:, params: nil)
190
- @request_id += 1
191
-
192
- message = {
193
- jsonrpc: "2.0",
194
- id: @request_id,
195
- method: method
196
- }
197
- message[:params] = params if params
198
-
199
- response = if @poller && @transport.is_a?(Transports::Stdio)
200
- @poller.send_request(self, message, timeout: @server.timeout)
201
- else
202
- @transport.send_request(message)
203
- end
204
-
205
- parse_response(response)
206
- end
207
-
208
- def parse_response(response)
209
- case response
210
- when String
211
- parsed = JSON.parse(response, symbolize_names: true)
212
- parsed[:result] || parsed
213
- when Hash
214
- response[:result] || response
215
- else
216
- response
217
- end
218
- rescue JSON::ParserError
219
- { raw: response }
220
- end
221
- end
222
- end
223
- end
@@ -1,195 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- # Multiplexes I/O across multiple stdio MCP transports.
6
- #
7
- # When a robot connects to more than one local (stdio) MCP server,
8
- # the default approach blocks independently per client, each with its
9
- # own Timeout.timeout wrapper. ConnectionPoller replaces this with a
10
- # single IO.select call across all registered stdout file descriptors,
11
- # dispatching each response to the pending request for that client.
12
- #
13
- # Async-based transports (SSE, WebSocket, StreamableHTTP) are
14
- # unaffected — they already use the Async fiber scheduler.
15
- #
16
- # == Usage
17
- #
18
- # poller = MCP::ConnectionPoller.new.start
19
- # client = MCP::Client.new(server_config, poller: poller)
20
- # client.connect # registers transport with poller
21
- # client.call_tool(…)
22
- # poller.stop
23
- #
24
- # @api private
25
- class ConnectionPoller
26
- POLL_INTERVAL = 0.1 # seconds between IO.select checks
27
-
28
- # Creates a new ConnectionPoller.
29
- def initialize
30
- @mutex = Mutex.new
31
- @clients = {} # IO => { client:, queue: Thread::Queue }
32
- @running = false
33
- @thread = nil
34
- end
35
-
36
- # Start the multiplexing thread.
37
- #
38
- # @return [self]
39
- def start
40
- @mutex.synchronize do
41
- return self if @running
42
-
43
- @running = true
44
- @thread = Thread.new { poll_loop }
45
- @thread.name = "RobotLab::MCP::ConnectionPoller"
46
- end
47
- self
48
- end
49
-
50
- # Stop the multiplexing thread.
51
- #
52
- # Cancels all pending requests with an MCPError.
53
- #
54
- # @param timeout [Numeric] seconds to wait for thread to finish
55
- # @return [self]
56
- def stop(timeout: 5)
57
- @mutex.synchronize do
58
- return self unless @running
59
-
60
- @running = false
61
-
62
- # Unblock any pending request queues
63
- @clients.each_value do |entry|
64
- entry[:queue]&.push({ error: "ConnectionPoller stopped" })
65
- end
66
- end
67
-
68
- @thread&.join(timeout)
69
- @thread = nil
70
- self
71
- end
72
-
73
- # Register an MCP client with the poller.
74
- #
75
- # Only stdio clients are registered — others are silently ignored.
76
- #
77
- # @param client [MCP::Client]
78
- # @return [void]
79
- def register(client)
80
- return unless stdio_client?(client)
81
-
82
- io = client.transport.stdout
83
- @mutex.synchronize { @clients[io] = { client: client, queue: nil } }
84
- end
85
-
86
- # Unregister a client.
87
- #
88
- # @param client [MCP::Client]
89
- # @return [void]
90
- def unregister(client)
91
- return unless stdio_client?(client)
92
-
93
- io = client.transport.stdout
94
- @mutex.synchronize { @clients.delete(io) }
95
- end
96
-
97
- # Send a JSON-RPC request via the poller and block until the response.
98
- #
99
- # Writes to the client's stdin, registers a response queue, then
100
- # blocks until the poll loop dispatches the response.
101
- #
102
- # @param client [MCP::Client]
103
- # @param message [Hash] JSON-RPC message
104
- # @param timeout [Numeric] seconds before raising MCPError
105
- # @return [Hash] parsed response
106
- # @raise [MCPError] on timeout or connection error
107
- # :reek:TooManyStatements -- register/write/wait/cleanup must stay in one method so ensure releases the queue.
108
- # :reek:DuplicateMethodCall -- the repeated @mutex.synchronize/@clients[io] pairs are deliberately separate short critical
109
- # sections; holding the lock across the blocking write/wait would deadlock the poll loop.
110
- def send_request(client, message, timeout:)
111
- transport = client.transport
112
- io = transport.stdout
113
- stdin = transport.stdin
114
- queue = Thread::Queue.new
115
-
116
- @mutex.synchronize { @clients[io][:queue] = queue }
117
-
118
- begin
119
- stdin.puts(message.to_json)
120
- stdin.flush
121
- rescue Errno::EPIPE, IOError => e
122
- @mutex.synchronize { @clients[io][:queue] = nil }
123
- raise MCPError.new("MCP connection lost: #{e.message}", retryable: true)
124
- end
125
-
126
- response = Timeout.timeout(timeout) { queue.pop }
127
-
128
- if response.is_a?(Hash) && response[:error]
129
- raise MCPError, response[:error]
130
- end
131
-
132
- response
133
- rescue Timeout::Error
134
- @mutex.synchronize { @clients[io]&.[]= :queue, nil }
135
- raise MCPError.new("MCP server did not respond within #{timeout}s", retryable: true)
136
- ensure
137
- @mutex.synchronize { @clients[io]&.[]= :queue, nil }
138
- end
139
-
140
- # Whether the poller thread is running.
141
- #
142
- # @return [Boolean]
143
- def running?
144
- @mutex.synchronize { @running }
145
- end
146
-
147
- private
148
-
149
- def poll_loop
150
- loop do
151
- ios = @mutex.synchronize { @clients.keys.reject(&:closed?) }
152
-
153
- if ios.empty?
154
- sleep POLL_INTERVAL
155
- else
156
- begin
157
- ready, = IO.select(ios, nil, nil, POLL_INTERVAL)
158
- dispatch(ready) if ready
159
- rescue Errno::EBADF
160
- # A pipe was closed between the reject and IO.select — harmless, loop again
161
- end
162
- end
163
-
164
- break unless @mutex.synchronize { @running }
165
- end
166
- end
167
-
168
- # :reek:TooManyStatements -- read/parse/filter/route steps for each readable IO; each guard skips a non-response line.
169
- def dispatch(readable_ios)
170
- readable_ios.each do |io|
171
- line = io.gets rescue nil
172
- next unless line
173
-
174
- parsed = JSON.parse(line, symbolize_names: true) rescue nil
175
- next unless parsed
176
-
177
- # Skip notifications (method present, no id)
178
- next if parsed[:method] && !parsed.key?(:id)
179
-
180
- entry = @mutex.synchronize { @clients[io] }
181
- entry[:queue]&.push(parsed)
182
- end
183
- end
184
-
185
- def stdio_client?(client)
186
- return false unless client.respond_to?(:transport)
187
-
188
- transport = client.transport
189
- transport.is_a?(Transports::Stdio) &&
190
- transport.respond_to?(:stdout) &&
191
- !transport.stdout.nil?
192
- end
193
- end
194
- end
195
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- module Transports
6
- # Base class for MCP transports
7
- #
8
- # @abstract Subclass and implement {#connect}, {#send_request}, {#close}
9
- #
10
- class Base
11
- # Default timeout for request operations (in seconds)
12
- DEFAULT_TIMEOUT = 15
13
-
14
- # @!attribute [r] config
15
- # @return [Hash] the transport configuration
16
- # @!attribute [r] timeout
17
- # @return [Numeric] request timeout in seconds
18
- attr_reader :config, :timeout
19
-
20
- # Creates a new transport instance.
21
- #
22
- # @param config [Hash] transport configuration options.
23
- # May include a :timeout key (in seconds) that controls how long
24
- # blocking operations wait before raising an error.
25
- def initialize(config)
26
- @config = config.transform_keys(&:to_sym)
27
- @timeout = @config.delete(:timeout) || DEFAULT_TIMEOUT
28
- end
29
-
30
- # Connect to the server
31
- #
32
- # @return [self]
33
- #
34
- def connect
35
- raise NotImplementedError
36
- end
37
-
38
- # Send a JSON-RPC request
39
- #
40
- # @param message [Hash] JSON-RPC message
41
- # @return [Hash] Response
42
- #
43
- def send_request(message)
44
- raise NotImplementedError
45
- end
46
-
47
- # Close the connection
48
- #
49
- # @return [self]
50
- #
51
- def close
52
- raise NotImplementedError
53
- end
54
-
55
- # Check if the transport is connected.
56
- #
57
- # @return [Boolean] true if connected
58
- def connected?
59
- false
60
- end
61
- end
62
- end
63
- end
64
- end
@@ -1,120 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- module Transports
6
- # Server-Sent Events transport for MCP servers
7
- #
8
- # Uses async-http for SSE streaming.
9
- #
10
- # @example
11
- # transport = SSE.new(url: "http://localhost:8080/sse")
12
- #
13
- # :reek:InstanceVariableAssumption -- @config is assigned in Base#initialize (reek does not trace super).
14
- # :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every operation must check it.
15
- class SSE < Base
16
- # Creates a new SSE transport.
17
- #
18
- # @param config [Hash] transport configuration
19
- # @option config [String] :url SSE server URL
20
- def initialize(config)
21
- super
22
- @client = nil
23
- @connected = false
24
- @event_queue = []
25
- end
26
-
27
- # Connect to the MCP server via SSE.
28
- #
29
- # @return [self]
30
- # @raise [MCPError] if async-http gem is not available
31
- # :reek:TooManyStatements -- linear require/connect/handshake sequence inside the Async block.
32
- def connect
33
- return self if @connected
34
-
35
- require "async"
36
- require "async/http/client"
37
- require "async/http/endpoint"
38
-
39
- url = @config[:url]
40
-
41
- Async do
42
- endpoint = Async::HTTP::Endpoint.parse(url)
43
- @client = Async::HTTP::Client.new(endpoint)
44
- @connected = true
45
-
46
- # Initialize MCP protocol
47
- send_initialize
48
- end
49
-
50
- self
51
- rescue LoadError => e
52
- raise MCPError, "async-http gem required for SSE transport: #{e.message}"
53
- end
54
-
55
- # Send a JSON-RPC request to the MCP server.
56
- #
57
- # @param message [Hash] JSON-RPC message
58
- # @return [Hash] the response
59
- # @raise [MCPError] if not connected
60
- def send_request(message)
61
- raise MCPError, "Not connected" unless @connected
62
-
63
- require "async"
64
- require "async/http/body/writable"
65
-
66
- Async do
67
- # POST the request
68
- response = @client.post(
69
- @config[:url],
70
- { "Content-Type" => "application/json" },
71
- [message.to_json]
72
- )
73
-
74
- # Read response
75
- body = response.read
76
- JSON.parse(body, symbolize_names: true)
77
- end.wait
78
- end
79
-
80
- # Close the SSE connection.
81
- #
82
- # @return [self]
83
- def close
84
- return self unless @connected
85
-
86
- @client&.close
87
- @connected = false
88
- @client = nil
89
-
90
- self
91
- end
92
-
93
- # Check if the transport is connected.
94
- #
95
- # @return [Boolean] true if connected
96
- def connected?
97
- @connected
98
- end
99
-
100
- private
101
-
102
- def send_initialize
103
- send_request(
104
- jsonrpc: "2.0",
105
- id: 0,
106
- method: "initialize",
107
- params: {
108
- protocolVersion: "2024-11-05",
109
- capabilities: {},
110
- clientInfo: {
111
- name: "RobotLab",
112
- version: RobotLab::VERSION
113
- }
114
- }
115
- )
116
- end
117
- end
118
- end
119
- end
120
- end