robot_lab 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -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 +259 -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
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "open3"
|
|
4
|
-
require "json"
|
|
5
|
-
require "timeout"
|
|
6
|
-
|
|
7
|
-
module RobotLab
|
|
8
|
-
module MCP
|
|
9
|
-
module Transports
|
|
10
|
-
# StdIO transport for local MCP servers
|
|
11
|
-
#
|
|
12
|
-
# Spawns a subprocess and communicates via stdin/stdout.
|
|
13
|
-
# All blocking I/O is wrapped with a configurable timeout
|
|
14
|
-
# so a missing or hung server cannot block the caller forever.
|
|
15
|
-
#
|
|
16
|
-
# @example
|
|
17
|
-
# transport = Stdio.new(
|
|
18
|
-
# { command: "mcp-server-filesystem", args: ["--root", "/data"] },
|
|
19
|
-
# timeout: 10
|
|
20
|
-
# )
|
|
21
|
-
#
|
|
22
|
-
# :reek:InstanceVariableAssumption -- @config and @timeout are assigned in Base#initialize (reek does not trace super).
|
|
23
|
-
# :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every IO operation must check it.
|
|
24
|
-
class Stdio < Base
|
|
25
|
-
# Creates a new Stdio transport.
|
|
26
|
-
#
|
|
27
|
-
# @param config [Hash] transport configuration
|
|
28
|
-
# @option config [String] :command the command to execute
|
|
29
|
-
# @option config [Array<String>] :args command arguments
|
|
30
|
-
# @option config [Hash] :env environment variables
|
|
31
|
-
# @option config [Numeric] :timeout request timeout in seconds (default: 15)
|
|
32
|
-
def initialize(config)
|
|
33
|
-
super
|
|
34
|
-
@stdin = nil
|
|
35
|
-
@stdout = nil
|
|
36
|
-
@stderr = nil
|
|
37
|
-
@wait_thread = nil
|
|
38
|
-
@connected = false
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Connect to the MCP server via stdio.
|
|
42
|
-
#
|
|
43
|
-
# @return [self]
|
|
44
|
-
# @raise [MCPError] if the server process cannot be started or does not
|
|
45
|
-
# respond to the MCP initialize handshake within the timeout period
|
|
46
|
-
# :reek:TooManyStatements -- linear spawn/verify/handshake sequence with per-failure-mode rescues.
|
|
47
|
-
def connect
|
|
48
|
-
return self if @connected
|
|
49
|
-
|
|
50
|
-
command = @config[:command]
|
|
51
|
-
args = @config[:args] || []
|
|
52
|
-
env = @config[:env] || {}
|
|
53
|
-
|
|
54
|
-
full_env = ENV.to_h.merge(env.transform_keys(&:to_s))
|
|
55
|
-
|
|
56
|
-
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(full_env, command, *args)
|
|
57
|
-
|
|
58
|
-
# Verify the process actually started
|
|
59
|
-
unless @wait_thread.alive?
|
|
60
|
-
raise MCPError, "MCP server process exited immediately (command: #{command})"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
@connected = true
|
|
64
|
-
|
|
65
|
-
# Initialize MCP protocol — this must complete within the timeout
|
|
66
|
-
send_initialize
|
|
67
|
-
|
|
68
|
-
self
|
|
69
|
-
rescue Errno::ENOENT => e
|
|
70
|
-
cleanup_process
|
|
71
|
-
raise MCPError, "MCP server command not found: #{command} (#{e.message})"
|
|
72
|
-
rescue Timeout::Error
|
|
73
|
-
cleanup_process
|
|
74
|
-
raise MCPError, "MCP server '#{command}' did not respond within #{@timeout}s"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Send a JSON-RPC request to the MCP server.
|
|
78
|
-
#
|
|
79
|
-
# @param message [Hash] JSON-RPC message
|
|
80
|
-
# @return [Hash] the response
|
|
81
|
-
# @raise [MCPError] if not connected, no response, or timeout
|
|
82
|
-
# :reek:TooManyStatements -- write-then-read loop must stay inside the one Timeout block.
|
|
83
|
-
def send_request(message)
|
|
84
|
-
raise MCPError, "Not connected" unless @connected
|
|
85
|
-
|
|
86
|
-
Timeout.timeout(@timeout, Timeout::Error) do
|
|
87
|
-
json = message.to_json
|
|
88
|
-
@stdin.puts(json)
|
|
89
|
-
@stdin.flush
|
|
90
|
-
|
|
91
|
-
loop do
|
|
92
|
-
response_line = @stdout.gets
|
|
93
|
-
raise MCPError, "No response from MCP server (EOF on stdout)" unless response_line
|
|
94
|
-
|
|
95
|
-
parsed = JSON.parse(response_line, symbolize_names: true)
|
|
96
|
-
|
|
97
|
-
# Skip notifications (messages without an id)
|
|
98
|
-
next if parsed[:method] && !parsed.key?(:id)
|
|
99
|
-
|
|
100
|
-
return parsed
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
rescue Timeout::Error
|
|
104
|
-
raise MCPError, "MCP server did not respond within #{@timeout}s"
|
|
105
|
-
rescue Errno::EPIPE, IOError => e
|
|
106
|
-
@connected = false
|
|
107
|
-
raise MCPError, "MCP server connection lost: #{e.message}"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Close the connection to the MCP server.
|
|
111
|
-
#
|
|
112
|
-
# @return [self]
|
|
113
|
-
def close
|
|
114
|
-
return self unless @connected
|
|
115
|
-
|
|
116
|
-
cleanup_process
|
|
117
|
-
self
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# Check if the transport is connected.
|
|
121
|
-
#
|
|
122
|
-
# @return [Boolean] true if connected and process is alive
|
|
123
|
-
def connected?
|
|
124
|
-
@connected && @wait_thread&.alive?
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Expose the underlying IO objects so ConnectionPoller can
|
|
128
|
-
# register them in its IO.select loop.
|
|
129
|
-
#
|
|
130
|
-
# @api private
|
|
131
|
-
attr_reader :stdin, :stdout
|
|
132
|
-
|
|
133
|
-
private
|
|
134
|
-
|
|
135
|
-
def send_initialize
|
|
136
|
-
send_request(
|
|
137
|
-
jsonrpc: "2.0",
|
|
138
|
-
id: 0,
|
|
139
|
-
method: "initialize",
|
|
140
|
-
params: {
|
|
141
|
-
protocolVersion: "2024-11-05",
|
|
142
|
-
capabilities: {},
|
|
143
|
-
clientInfo: {
|
|
144
|
-
name: "RobotLab",
|
|
145
|
-
version: RobotLab::VERSION
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
# Send initialized notification
|
|
151
|
-
@stdin.puts({ jsonrpc: "2.0", method: "notifications/initialized" }.to_json)
|
|
152
|
-
@stdin.flush
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# :reek:TooManyStatements -- best-effort teardown; each handle is closed and nilled independently.
|
|
156
|
-
def cleanup_process
|
|
157
|
-
@connected = false
|
|
158
|
-
@stdin&.close rescue nil
|
|
159
|
-
@stdout&.close rescue nil
|
|
160
|
-
@stderr&.close rescue nil
|
|
161
|
-
@wait_thread&.kill if @wait_thread&.alive?
|
|
162
|
-
@stdin = nil
|
|
163
|
-
@stdout = nil
|
|
164
|
-
@stderr = nil
|
|
165
|
-
@wait_thread = nil
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RobotLab
|
|
4
|
-
module MCP
|
|
5
|
-
module Transports
|
|
6
|
-
# Streamable HTTP transport for MCP servers
|
|
7
|
-
#
|
|
8
|
-
# Supports session management and reconnection.
|
|
9
|
-
#
|
|
10
|
-
# @example
|
|
11
|
-
# transport = StreamableHTTP.new(
|
|
12
|
-
# url: "https://server.smithery.ai/neon/mcp",
|
|
13
|
-
# session_id: "abc123"
|
|
14
|
-
# )
|
|
15
|
-
#
|
|
16
|
-
# :reek:InstanceVariableAssumption -- @config is assigned in Base#initialize (reek does not trace super).
|
|
17
|
-
# :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every operation must check it.
|
|
18
|
-
class StreamableHTTP < Base
|
|
19
|
-
# Creates a new StreamableHTTP transport.
|
|
20
|
-
#
|
|
21
|
-
# @param config [Hash] transport configuration
|
|
22
|
-
# @option config [String] :url HTTP server URL
|
|
23
|
-
# @option config [String] :session_id optional session identifier
|
|
24
|
-
# @option config [Proc] :auth_provider optional authentication callback
|
|
25
|
-
def initialize(config)
|
|
26
|
-
super
|
|
27
|
-
@client = nil
|
|
28
|
-
@connected = false
|
|
29
|
-
@session_id = config[:session_id]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Connect to the MCP server via HTTP.
|
|
33
|
-
#
|
|
34
|
-
# @return [self]
|
|
35
|
-
# @raise [MCPError] if async-http gem is not available
|
|
36
|
-
# :reek:TooManyStatements -- linear require/connect/handshake sequence inside the Async block.
|
|
37
|
-
def connect
|
|
38
|
-
return self if @connected
|
|
39
|
-
|
|
40
|
-
require "async"
|
|
41
|
-
require "async/http/client"
|
|
42
|
-
require "async/http/endpoint"
|
|
43
|
-
|
|
44
|
-
url = @config[:url]
|
|
45
|
-
|
|
46
|
-
Async do
|
|
47
|
-
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
48
|
-
@client = Async::HTTP::Client.new(endpoint)
|
|
49
|
-
@connected = true
|
|
50
|
-
|
|
51
|
-
# Initialize MCP protocol
|
|
52
|
-
result = send_initialize
|
|
53
|
-
@session_id ||= result&.dig(:serverInfo, :sessionId)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
self
|
|
57
|
-
rescue LoadError => e
|
|
58
|
-
raise MCPError, "async-http gem required for HTTP transport: #{e.message}"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Send a JSON-RPC request to the MCP server.
|
|
62
|
-
#
|
|
63
|
-
# @param message [Hash] JSON-RPC message
|
|
64
|
-
# @return [Hash] the response
|
|
65
|
-
# @raise [MCPError] if not connected
|
|
66
|
-
# :reek:TooManyStatements -- header assembly and POST must stay inside the one Async block.
|
|
67
|
-
def send_request(message)
|
|
68
|
-
raise MCPError, "Not connected" unless @connected
|
|
69
|
-
|
|
70
|
-
require "async"
|
|
71
|
-
|
|
72
|
-
Async do
|
|
73
|
-
headers = {
|
|
74
|
-
"Content-Type" => "application/json",
|
|
75
|
-
"Accept" => "application/json"
|
|
76
|
-
}
|
|
77
|
-
headers["X-Session-ID"] = @session_id if @session_id
|
|
78
|
-
|
|
79
|
-
# Add auth if configured
|
|
80
|
-
if @config[:auth_provider]
|
|
81
|
-
auth_header = @config[:auth_provider].call
|
|
82
|
-
headers["Authorization"] = auth_header if auth_header
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
response = @client.post(
|
|
86
|
-
@config[:url],
|
|
87
|
-
headers,
|
|
88
|
-
[message.to_json]
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
body = response.read
|
|
92
|
-
JSON.parse(body, symbolize_names: true)
|
|
93
|
-
end.wait
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Close the HTTP connection.
|
|
97
|
-
#
|
|
98
|
-
# @return [self]
|
|
99
|
-
def close
|
|
100
|
-
return self unless @connected
|
|
101
|
-
|
|
102
|
-
@client&.close
|
|
103
|
-
@connected = false
|
|
104
|
-
@client = nil
|
|
105
|
-
|
|
106
|
-
self
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Check if the transport is connected.
|
|
110
|
-
#
|
|
111
|
-
# @return [Boolean] true if connected
|
|
112
|
-
def connected?
|
|
113
|
-
@connected
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Returns the session identifier.
|
|
117
|
-
#
|
|
118
|
-
# @return [String, nil] the session ID
|
|
119
|
-
attr_reader :session_id
|
|
120
|
-
|
|
121
|
-
private
|
|
122
|
-
|
|
123
|
-
def send_initialize
|
|
124
|
-
send_request(
|
|
125
|
-
jsonrpc: "2.0",
|
|
126
|
-
id: 0,
|
|
127
|
-
method: "initialize",
|
|
128
|
-
params: {
|
|
129
|
-
protocolVersion: "2024-11-05",
|
|
130
|
-
capabilities: {},
|
|
131
|
-
clientInfo: {
|
|
132
|
-
name: "RobotLab",
|
|
133
|
-
version: RobotLab::VERSION
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
)
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
end
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RobotLab
|
|
4
|
-
module MCP
|
|
5
|
-
module Transports
|
|
6
|
-
# WebSocket transport for MCP servers
|
|
7
|
-
#
|
|
8
|
-
# Uses async-websocket for non-blocking communication.
|
|
9
|
-
#
|
|
10
|
-
# @example
|
|
11
|
-
# transport = WebSocket.new(url: "ws://localhost:8080")
|
|
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 WebSocket < Base
|
|
16
|
-
# Creates a new WebSocket transport.
|
|
17
|
-
#
|
|
18
|
-
# @param config [Hash] transport configuration
|
|
19
|
-
# @option config [String] :url WebSocket server URL
|
|
20
|
-
def initialize(config)
|
|
21
|
-
super
|
|
22
|
-
@connection = nil
|
|
23
|
-
@connected = false
|
|
24
|
-
@pending_requests = {}
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Connect to the MCP server via WebSocket.
|
|
28
|
-
#
|
|
29
|
-
# @return [self]
|
|
30
|
-
# @raise [MCPError] if async-websocket 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/websocket/client"
|
|
37
|
-
|
|
38
|
-
url = @config[:url]
|
|
39
|
-
|
|
40
|
-
Async do
|
|
41
|
-
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
42
|
-
@connection = Async::WebSocket::Client.connect(endpoint)
|
|
43
|
-
@connected = true
|
|
44
|
-
|
|
45
|
-
# Initialize MCP protocol
|
|
46
|
-
send_initialize
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
self
|
|
50
|
-
rescue LoadError => e
|
|
51
|
-
raise MCPError, "async-websocket gem required for WebSocket transport: #{e.message}"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Send a JSON-RPC request to the MCP server.
|
|
55
|
-
#
|
|
56
|
-
# @param message [Hash] JSON-RPC message
|
|
57
|
-
# @return [Hash] the response
|
|
58
|
-
# @raise [MCPError] if not connected
|
|
59
|
-
def send_request(message)
|
|
60
|
-
raise MCPError, "Not connected" unless @connected
|
|
61
|
-
|
|
62
|
-
Async do
|
|
63
|
-
@connection.write(message.to_json)
|
|
64
|
-
@connection.flush
|
|
65
|
-
|
|
66
|
-
response_text = @connection.read
|
|
67
|
-
JSON.parse(response_text, symbolize_names: true)
|
|
68
|
-
end.wait
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Close the WebSocket connection.
|
|
72
|
-
#
|
|
73
|
-
# @return [self]
|
|
74
|
-
def close
|
|
75
|
-
return self unless @connected
|
|
76
|
-
|
|
77
|
-
@connection&.close
|
|
78
|
-
@connected = false
|
|
79
|
-
@connection = nil
|
|
80
|
-
|
|
81
|
-
self
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Check if the transport is connected.
|
|
85
|
-
#
|
|
86
|
-
# @return [Boolean] true if connected
|
|
87
|
-
def connected?
|
|
88
|
-
@connected
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private
|
|
92
|
-
|
|
93
|
-
def send_initialize
|
|
94
|
-
send_request(
|
|
95
|
-
jsonrpc: "2.0",
|
|
96
|
-
id: 0,
|
|
97
|
-
method: "initialize",
|
|
98
|
-
params: {
|
|
99
|
-
protocolVersion: "2024-11-05",
|
|
100
|
-
capabilities: {},
|
|
101
|
-
clientInfo: {
|
|
102
|
-
name: "RobotLab",
|
|
103
|
-
version: RobotLab::VERSION
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
)
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|