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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +28 -18
- data/lib/robot_lab/errors.rb +4 -0
- data/lib/robot_lab/hook_context.rb +9 -2
- data/lib/robot_lab/mcp/registry.rb +99 -0
- data/lib/robot_lab/mcp/server.rb +47 -83
- data/lib/robot_lab/mcp/server_discovery.rb +22 -9
- data/lib/robot_lab/mcp.rb +267 -0
- data/lib/robot_lab/robot/mcp_management.rb +159 -105
- data/lib/robot_lab/robot/tool_call_gating.rb +144 -0
- data/lib/robot_lab/robot.rb +72 -99
- data/lib/robot_lab/run_config.rb +3 -2
- data/lib/robot_lab/tool.rb +8 -23
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +1 -4
- metadata +14 -40
- data/lib/robot_lab/mcp/client.rb +0 -223
- data/lib/robot_lab/mcp/connection_poller.rb +0 -195
- data/lib/robot_lab/mcp/transports/base.rb +0 -64
- data/lib/robot_lab/mcp/transports/sse.rb +0 -120
- data/lib/robot_lab/mcp/transports/stdio.rb +0 -170
- data/lib/robot_lab/mcp/transports/streamable_http.rb +0 -141
- data/lib/robot_lab/mcp/transports/websocket.rb +0 -111
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.
|
|
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:
|
|
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:
|
|
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
|
|
218
|
-
- lib/robot_lab/mcp/
|
|
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
|
data/lib/robot_lab/mcp/client.rb
DELETED
|
@@ -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
|