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.
@@ -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