robot_lab 0.0.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.
Files changed (153) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.github/workflows/deploy-github-pages.yml +52 -0
  4. data/.github/workflows/deploy-yard-docs.yml +52 -0
  5. data/CHANGELOG.md +55 -0
  6. data/COMMITS.md +196 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +332 -0
  9. data/Rakefile +67 -0
  10. data/docs/api/adapters/anthropic.md +121 -0
  11. data/docs/api/adapters/gemini.md +133 -0
  12. data/docs/api/adapters/index.md +104 -0
  13. data/docs/api/adapters/openai.md +134 -0
  14. data/docs/api/core/index.md +113 -0
  15. data/docs/api/core/memory.md +314 -0
  16. data/docs/api/core/network.md +291 -0
  17. data/docs/api/core/robot.md +273 -0
  18. data/docs/api/core/state.md +273 -0
  19. data/docs/api/core/tool.md +353 -0
  20. data/docs/api/history/active-record-adapter.md +195 -0
  21. data/docs/api/history/config.md +191 -0
  22. data/docs/api/history/index.md +132 -0
  23. data/docs/api/history/thread-manager.md +144 -0
  24. data/docs/api/index.md +82 -0
  25. data/docs/api/mcp/client.md +221 -0
  26. data/docs/api/mcp/index.md +111 -0
  27. data/docs/api/mcp/server.md +225 -0
  28. data/docs/api/mcp/transports.md +264 -0
  29. data/docs/api/messages/index.md +67 -0
  30. data/docs/api/messages/text-message.md +102 -0
  31. data/docs/api/messages/tool-call-message.md +144 -0
  32. data/docs/api/messages/tool-result-message.md +154 -0
  33. data/docs/api/messages/user-message.md +171 -0
  34. data/docs/api/streaming/context.md +174 -0
  35. data/docs/api/streaming/events.md +237 -0
  36. data/docs/api/streaming/index.md +108 -0
  37. data/docs/architecture/core-concepts.md +243 -0
  38. data/docs/architecture/index.md +138 -0
  39. data/docs/architecture/message-flow.md +320 -0
  40. data/docs/architecture/network-orchestration.md +216 -0
  41. data/docs/architecture/robot-execution.md +243 -0
  42. data/docs/architecture/state-management.md +323 -0
  43. data/docs/assets/css/custom.css +56 -0
  44. data/docs/assets/images/robot_lab.jpg +0 -0
  45. data/docs/concepts.md +216 -0
  46. data/docs/examples/basic-chat.md +193 -0
  47. data/docs/examples/index.md +129 -0
  48. data/docs/examples/mcp-server.md +290 -0
  49. data/docs/examples/multi-robot-network.md +312 -0
  50. data/docs/examples/rails-application.md +420 -0
  51. data/docs/examples/tool-usage.md +310 -0
  52. data/docs/getting-started/configuration.md +230 -0
  53. data/docs/getting-started/index.md +56 -0
  54. data/docs/getting-started/installation.md +179 -0
  55. data/docs/getting-started/quick-start.md +203 -0
  56. data/docs/guides/building-robots.md +376 -0
  57. data/docs/guides/creating-networks.md +366 -0
  58. data/docs/guides/history.md +359 -0
  59. data/docs/guides/index.md +68 -0
  60. data/docs/guides/mcp-integration.md +356 -0
  61. data/docs/guides/memory.md +309 -0
  62. data/docs/guides/rails-integration.md +432 -0
  63. data/docs/guides/streaming.md +314 -0
  64. data/docs/guides/using-tools.md +394 -0
  65. data/docs/index.md +160 -0
  66. data/examples/01_simple_robot.rb +38 -0
  67. data/examples/02_tools.rb +106 -0
  68. data/examples/03_network.rb +103 -0
  69. data/examples/04_mcp.rb +219 -0
  70. data/examples/05_streaming.rb +124 -0
  71. data/examples/06_prompt_templates.rb +324 -0
  72. data/examples/07_network_memory.rb +329 -0
  73. data/examples/prompts/assistant/system.txt.erb +2 -0
  74. data/examples/prompts/assistant/user.txt.erb +1 -0
  75. data/examples/prompts/billing/system.txt.erb +7 -0
  76. data/examples/prompts/billing/user.txt.erb +1 -0
  77. data/examples/prompts/classifier/system.txt.erb +4 -0
  78. data/examples/prompts/classifier/user.txt.erb +1 -0
  79. data/examples/prompts/entity_extractor/system.txt.erb +11 -0
  80. data/examples/prompts/entity_extractor/user.txt.erb +3 -0
  81. data/examples/prompts/escalation/system.txt.erb +35 -0
  82. data/examples/prompts/escalation/user.txt.erb +34 -0
  83. data/examples/prompts/general/system.txt.erb +4 -0
  84. data/examples/prompts/general/user.txt.erb +1 -0
  85. data/examples/prompts/github_assistant/system.txt.erb +6 -0
  86. data/examples/prompts/github_assistant/user.txt.erb +1 -0
  87. data/examples/prompts/helper/system.txt.erb +1 -0
  88. data/examples/prompts/helper/user.txt.erb +1 -0
  89. data/examples/prompts/keyword_extractor/system.txt.erb +8 -0
  90. data/examples/prompts/keyword_extractor/user.txt.erb +3 -0
  91. data/examples/prompts/order_support/system.txt.erb +27 -0
  92. data/examples/prompts/order_support/user.txt.erb +22 -0
  93. data/examples/prompts/product_support/system.txt.erb +30 -0
  94. data/examples/prompts/product_support/user.txt.erb +32 -0
  95. data/examples/prompts/sentiment_analyzer/system.txt.erb +9 -0
  96. data/examples/prompts/sentiment_analyzer/user.txt.erb +3 -0
  97. data/examples/prompts/synthesizer/system.txt.erb +14 -0
  98. data/examples/prompts/synthesizer/user.txt.erb +15 -0
  99. data/examples/prompts/technical/system.txt.erb +7 -0
  100. data/examples/prompts/technical/user.txt.erb +1 -0
  101. data/examples/prompts/triage/system.txt.erb +16 -0
  102. data/examples/prompts/triage/user.txt.erb +17 -0
  103. data/lib/generators/robot_lab/install_generator.rb +78 -0
  104. data/lib/generators/robot_lab/robot_generator.rb +55 -0
  105. data/lib/generators/robot_lab/templates/initializer.rb.tt +41 -0
  106. data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
  107. data/lib/generators/robot_lab/templates/result_model.rb.tt +52 -0
  108. data/lib/generators/robot_lab/templates/robot.rb.tt +46 -0
  109. data/lib/generators/robot_lab/templates/robot_test.rb.tt +32 -0
  110. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +53 -0
  111. data/lib/generators/robot_lab/templates/thread_model.rb.tt +40 -0
  112. data/lib/robot_lab/adapters/anthropic.rb +163 -0
  113. data/lib/robot_lab/adapters/base.rb +85 -0
  114. data/lib/robot_lab/adapters/gemini.rb +193 -0
  115. data/lib/robot_lab/adapters/openai.rb +159 -0
  116. data/lib/robot_lab/adapters/registry.rb +81 -0
  117. data/lib/robot_lab/configuration.rb +143 -0
  118. data/lib/robot_lab/error.rb +32 -0
  119. data/lib/robot_lab/errors.rb +70 -0
  120. data/lib/robot_lab/history/active_record_adapter.rb +146 -0
  121. data/lib/robot_lab/history/config.rb +115 -0
  122. data/lib/robot_lab/history/thread_manager.rb +93 -0
  123. data/lib/robot_lab/mcp/client.rb +210 -0
  124. data/lib/robot_lab/mcp/server.rb +84 -0
  125. data/lib/robot_lab/mcp/transports/base.rb +56 -0
  126. data/lib/robot_lab/mcp/transports/sse.rb +117 -0
  127. data/lib/robot_lab/mcp/transports/stdio.rb +133 -0
  128. data/lib/robot_lab/mcp/transports/streamable_http.rb +139 -0
  129. data/lib/robot_lab/mcp/transports/websocket.rb +108 -0
  130. data/lib/robot_lab/memory.rb +882 -0
  131. data/lib/robot_lab/memory_change.rb +123 -0
  132. data/lib/robot_lab/message.rb +357 -0
  133. data/lib/robot_lab/network.rb +350 -0
  134. data/lib/robot_lab/rails/engine.rb +29 -0
  135. data/lib/robot_lab/rails/railtie.rb +42 -0
  136. data/lib/robot_lab/robot.rb +560 -0
  137. data/lib/robot_lab/robot_result.rb +205 -0
  138. data/lib/robot_lab/robotic_model.rb +324 -0
  139. data/lib/robot_lab/state_proxy.rb +188 -0
  140. data/lib/robot_lab/streaming/context.rb +144 -0
  141. data/lib/robot_lab/streaming/events.rb +95 -0
  142. data/lib/robot_lab/streaming/sequence_counter.rb +48 -0
  143. data/lib/robot_lab/task.rb +117 -0
  144. data/lib/robot_lab/tool.rb +223 -0
  145. data/lib/robot_lab/tool_config.rb +112 -0
  146. data/lib/robot_lab/tool_manifest.rb +234 -0
  147. data/lib/robot_lab/user_message.rb +118 -0
  148. data/lib/robot_lab/version.rb +5 -0
  149. data/lib/robot_lab/waiter.rb +73 -0
  150. data/lib/robot_lab.rb +195 -0
  151. data/mkdocs.yml +214 -0
  152. data/sig/robot_lab.rbs +4 -0
  153. metadata +442 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module MCP
5
+ # Configuration for an MCP server connection
6
+ #
7
+ # @example WebSocket transport
8
+ # Server.new(
9
+ # name: "neon",
10
+ # transport: { type: "ws", url: "ws://localhost:8080" }
11
+ # )
12
+ #
13
+ # @example StdIO transport
14
+ # Server.new(
15
+ # name: "filesystem",
16
+ # transport: {
17
+ # type: "stdio",
18
+ # command: "mcp-server-filesystem",
19
+ # args: ["--root", "/data"]
20
+ # }
21
+ # )
22
+ #
23
+ class Server
24
+ # Valid transport types for MCP connections
25
+ VALID_TRANSPORT_TYPES = %w[stdio sse ws websocket streamable-http http].freeze
26
+
27
+ # @!attribute [r] name
28
+ # @return [String] the server name
29
+ # @!attribute [r] transport
30
+ # @return [Hash] the transport configuration
31
+ attr_reader :name, :transport
32
+
33
+ # Creates a new Server configuration.
34
+ #
35
+ # @param name [String] the server name
36
+ # @param transport [Hash] the transport configuration
37
+ # @raise [ArgumentError] if transport type is invalid or required fields are missing
38
+ def initialize(name:, transport:)
39
+ @name = name.to_s
40
+ @transport = normalize_transport(transport)
41
+ validate!
42
+ end
43
+
44
+ # Returns the transport type.
45
+ #
46
+ # @return [String] the transport type (stdio, sse, ws, etc.)
47
+ def transport_type
48
+ @transport[:type]
49
+ end
50
+
51
+ # Converts the server configuration to a hash.
52
+ #
53
+ # @return [Hash]
54
+ def to_h
55
+ {
56
+ name: name,
57
+ transport: transport
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def normalize_transport(transport)
64
+ transport = transport.transform_keys(&:to_sym)
65
+ transport[:type] = transport[:type].to_s.downcase
66
+ transport
67
+ end
68
+
69
+ def validate!
70
+ unless VALID_TRANSPORT_TYPES.include?(transport_type)
71
+ raise ArgumentError, "Invalid transport type: #{transport_type}. " \
72
+ "Must be one of: #{VALID_TRANSPORT_TYPES.join(', ')}"
73
+ end
74
+
75
+ case transport_type
76
+ when "stdio"
77
+ raise ArgumentError, "StdIO transport requires :command" unless transport[:command]
78
+ when "ws", "websocket", "sse", "streamable-http", "http"
79
+ raise ArgumentError, "Transport requires :url" unless transport[:url]
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,56 @@
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
+ # @!attribute [r] config
12
+ # @return [Hash] the transport configuration
13
+ attr_reader :config
14
+
15
+ # Creates a new transport instance.
16
+ #
17
+ # @param config [Hash] transport configuration options
18
+ def initialize(config)
19
+ @config = config.transform_keys(&:to_sym)
20
+ end
21
+
22
+ # Connect to the server
23
+ #
24
+ # @return [self]
25
+ #
26
+ def connect
27
+ raise NotImplementedError
28
+ end
29
+
30
+ # Send a JSON-RPC request
31
+ #
32
+ # @param message [Hash] JSON-RPC message
33
+ # @return [Hash] Response
34
+ #
35
+ def send_request(message)
36
+ raise NotImplementedError
37
+ end
38
+
39
+ # Close the connection
40
+ #
41
+ # @return [self]
42
+ #
43
+ def close
44
+ raise NotImplementedError
45
+ end
46
+
47
+ # Check if the transport is connected.
48
+ #
49
+ # @return [Boolean] true if connected
50
+ def connected?
51
+ false
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,117 @@
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
+ class SSE < Base
14
+ # Creates a new SSE transport.
15
+ #
16
+ # @param config [Hash] transport configuration
17
+ # @option config [String] :url SSE server URL
18
+ def initialize(config)
19
+ super
20
+ @client = nil
21
+ @connected = false
22
+ @event_queue = []
23
+ end
24
+
25
+ # Connect to the MCP server via SSE.
26
+ #
27
+ # @return [self]
28
+ # @raise [MCPError] if async-http gem is not available
29
+ def connect
30
+ return self if @connected
31
+
32
+ require "async"
33
+ require "async/http/client"
34
+ require "async/http/endpoint"
35
+
36
+ url = @config[:url]
37
+
38
+ Async do
39
+ endpoint = Async::HTTP::Endpoint.parse(url)
40
+ @client = Async::HTTP::Client.new(endpoint)
41
+ @connected = true
42
+
43
+ # Initialize MCP protocol
44
+ send_initialize
45
+ end
46
+
47
+ self
48
+ rescue LoadError => e
49
+ raise MCPError, "async-http gem required for SSE transport: #{e.message}"
50
+ end
51
+
52
+ # Send a JSON-RPC request to the MCP server.
53
+ #
54
+ # @param message [Hash] JSON-RPC message
55
+ # @return [Hash] the response
56
+ # @raise [MCPError] if not connected
57
+ def send_request(message)
58
+ raise MCPError, "Not connected" unless @connected
59
+
60
+ require "async"
61
+ require "async/http/body/writable"
62
+
63
+ Async do
64
+ # POST the request
65
+ response = @client.post(
66
+ @config[:url],
67
+ { "Content-Type" => "application/json" },
68
+ [message.to_json]
69
+ )
70
+
71
+ # Read response
72
+ body = response.read
73
+ JSON.parse(body, symbolize_names: true)
74
+ end.wait
75
+ end
76
+
77
+ # Close the SSE connection.
78
+ #
79
+ # @return [self]
80
+ def close
81
+ return self unless @connected
82
+
83
+ @client&.close
84
+ @connected = false
85
+ @client = nil
86
+
87
+ self
88
+ end
89
+
90
+ # Check if the transport is connected.
91
+ #
92
+ # @return [Boolean] true if connected
93
+ def connected?
94
+ @connected
95
+ end
96
+
97
+ private
98
+
99
+ def send_initialize
100
+ send_request(
101
+ jsonrpc: "2.0",
102
+ id: 0,
103
+ method: "initialize",
104
+ params: {
105
+ protocolVersion: "2024-11-05",
106
+ capabilities: {},
107
+ clientInfo: {
108
+ name: "RobotLab",
109
+ version: RobotLab::VERSION
110
+ }
111
+ }
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+
6
+ module RobotLab
7
+ module MCP
8
+ module Transports
9
+ # StdIO transport for local MCP servers
10
+ #
11
+ # Spawns a subprocess and communicates via stdin/stdout.
12
+ #
13
+ # @example
14
+ # transport = Stdio.new(
15
+ # command: "mcp-server-filesystem",
16
+ # args: ["--root", "/data"],
17
+ # env: { "DEBUG" => "true" }
18
+ # )
19
+ #
20
+ class Stdio < Base
21
+ # Creates a new Stdio transport.
22
+ #
23
+ # @param config [Hash] transport configuration
24
+ # @option config [String] :command the command to execute
25
+ # @option config [Array<String>] :args command arguments
26
+ # @option config [Hash] :env environment variables
27
+ def initialize(config)
28
+ super
29
+ @stdin = nil
30
+ @stdout = nil
31
+ @stderr = nil
32
+ @wait_thread = nil
33
+ @connected = false
34
+ end
35
+
36
+ # Connect to the MCP server via stdio.
37
+ #
38
+ # @return [self]
39
+ # @raise [MCPError] if connection fails
40
+ def connect
41
+ return self if @connected
42
+
43
+ command = @config[:command]
44
+ args = @config[:args] || []
45
+ env = @config[:env] || {}
46
+
47
+ # Merge with current environment
48
+ full_env = ENV.to_h.merge(env.transform_keys(&:to_s))
49
+
50
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(full_env, command, *args)
51
+ @connected = true
52
+
53
+ # Initialize MCP protocol
54
+ send_initialize
55
+
56
+ self
57
+ end
58
+
59
+ # Send a JSON-RPC request to the MCP server.
60
+ #
61
+ # @param message [Hash] JSON-RPC message
62
+ # @return [Hash] the response
63
+ # @raise [MCPError] if not connected or no response
64
+ def send_request(message)
65
+ raise MCPError, "Not connected" unless @connected
66
+
67
+ # Write JSON-RPC message
68
+ json = message.to_json
69
+ @stdin.puts(json)
70
+ @stdin.flush
71
+
72
+ # Read response, skipping notifications
73
+ loop do
74
+ response_line = @stdout.gets
75
+ raise MCPError, "No response from MCP server" unless response_line
76
+
77
+ parsed = JSON.parse(response_line, symbolize_names: true)
78
+
79
+ # Skip notifications (messages without an id)
80
+ next if parsed[:method] && !parsed.key?(:id)
81
+
82
+ # Return responses (messages with an id)
83
+ return parsed
84
+ end
85
+ end
86
+
87
+ # Close the connection to the MCP server.
88
+ #
89
+ # @return [self]
90
+ def close
91
+ return self unless @connected
92
+
93
+ @stdin&.close
94
+ @stdout&.close
95
+ @stderr&.close
96
+ @wait_thread&.kill if @wait_thread&.alive?
97
+
98
+ @connected = false
99
+ self
100
+ end
101
+
102
+ # Check if the transport is connected.
103
+ #
104
+ # @return [Boolean] true if connected and process is alive
105
+ def connected?
106
+ @connected && @wait_thread&.alive?
107
+ end
108
+
109
+ private
110
+
111
+ def send_initialize
112
+ send_request(
113
+ jsonrpc: "2.0",
114
+ id: 0,
115
+ method: "initialize",
116
+ params: {
117
+ protocolVersion: "2024-11-05",
118
+ capabilities: {},
119
+ clientInfo: {
120
+ name: "RobotLab",
121
+ version: RobotLab::VERSION
122
+ }
123
+ }
124
+ )
125
+
126
+ # Send initialized notification
127
+ @stdin.puts({ jsonrpc: "2.0", method: "notifications/initialized" }.to_json)
128
+ @stdin.flush
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,139 @@
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
+ class StreamableHTTP < Base
17
+ # Creates a new StreamableHTTP transport.
18
+ #
19
+ # @param config [Hash] transport configuration
20
+ # @option config [String] :url HTTP server URL
21
+ # @option config [String] :session_id optional session identifier
22
+ # @option config [Proc] :auth_provider optional authentication callback
23
+ def initialize(config)
24
+ super
25
+ @client = nil
26
+ @connected = false
27
+ @session_id = config[:session_id]
28
+ end
29
+
30
+ # Connect to the MCP server via HTTP.
31
+ #
32
+ # @return [self]
33
+ # @raise [MCPError] if async-http gem is not available
34
+ def connect
35
+ return self if @connected
36
+
37
+ require "async"
38
+ require "async/http/client"
39
+ require "async/http/endpoint"
40
+
41
+ url = @config[:url]
42
+
43
+ Async do
44
+ endpoint = Async::HTTP::Endpoint.parse(url)
45
+ @client = Async::HTTP::Client.new(endpoint)
46
+ @connected = true
47
+
48
+ # Initialize MCP protocol
49
+ result = send_initialize
50
+ @session_id ||= result.dig(:serverInfo, :sessionId)
51
+ end
52
+
53
+ self
54
+ rescue LoadError => e
55
+ raise MCPError, "async-http gem required for HTTP transport: #{e.message}"
56
+ end
57
+
58
+ # Send a JSON-RPC request to the MCP server.
59
+ #
60
+ # @param message [Hash] JSON-RPC message
61
+ # @return [Hash] the response
62
+ # @raise [MCPError] if not connected
63
+ def send_request(message)
64
+ raise MCPError, "Not connected" unless @connected
65
+
66
+ require "async"
67
+
68
+ Async do
69
+ headers = {
70
+ "Content-Type" => "application/json",
71
+ "Accept" => "application/json"
72
+ }
73
+ headers["X-Session-ID"] = @session_id if @session_id
74
+
75
+ # Add auth if configured
76
+ if @config[:auth_provider]
77
+ auth_header = @config[:auth_provider].call
78
+ headers["Authorization"] = auth_header if auth_header
79
+ end
80
+
81
+ response = @client.post(
82
+ @config[:url],
83
+ headers,
84
+ [message.to_json]
85
+ )
86
+
87
+ body = response.read
88
+ JSON.parse(body, symbolize_names: true)
89
+ end.wait
90
+ end
91
+
92
+ # Close the HTTP connection.
93
+ #
94
+ # @return [self]
95
+ def close
96
+ return self unless @connected
97
+
98
+ @client&.close
99
+ @connected = false
100
+ @client = nil
101
+
102
+ self
103
+ end
104
+
105
+ # Check if the transport is connected.
106
+ #
107
+ # @return [Boolean] true if connected
108
+ def connected?
109
+ @connected
110
+ end
111
+
112
+ # Returns the session identifier.
113
+ #
114
+ # @return [String, nil] the session ID
115
+ def session_id
116
+ @session_id
117
+ end
118
+
119
+ private
120
+
121
+ def send_initialize
122
+ send_request(
123
+ jsonrpc: "2.0",
124
+ id: 0,
125
+ method: "initialize",
126
+ params: {
127
+ protocolVersion: "2024-11-05",
128
+ capabilities: {},
129
+ clientInfo: {
130
+ name: "RobotLab",
131
+ version: RobotLab::VERSION
132
+ }
133
+ }
134
+ )
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,108 @@
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
+ class WebSocket < Base
14
+ # Creates a new WebSocket transport.
15
+ #
16
+ # @param config [Hash] transport configuration
17
+ # @option config [String] :url WebSocket server URL
18
+ def initialize(config)
19
+ super
20
+ @connection = nil
21
+ @connected = false
22
+ @pending_requests = {}
23
+ end
24
+
25
+ # Connect to the MCP server via WebSocket.
26
+ #
27
+ # @return [self]
28
+ # @raise [MCPError] if async-websocket gem is not available
29
+ def connect
30
+ return self if @connected
31
+
32
+ require "async"
33
+ require "async/websocket/client"
34
+
35
+ url = @config[:url]
36
+
37
+ Async do
38
+ endpoint = Async::HTTP::Endpoint.parse(url)
39
+ @connection = Async::WebSocket::Client.connect(endpoint)
40
+ @connected = true
41
+
42
+ # Initialize MCP protocol
43
+ send_initialize
44
+ end
45
+
46
+ self
47
+ rescue LoadError => e
48
+ raise MCPError, "async-websocket gem required for WebSocket transport: #{e.message}"
49
+ end
50
+
51
+ # Send a JSON-RPC request to the MCP server.
52
+ #
53
+ # @param message [Hash] JSON-RPC message
54
+ # @return [Hash] the response
55
+ # @raise [MCPError] if not connected
56
+ def send_request(message)
57
+ raise MCPError, "Not connected" unless @connected
58
+
59
+ Async do
60
+ @connection.write(message.to_json)
61
+ @connection.flush
62
+
63
+ response_text = @connection.read
64
+ JSON.parse(response_text, symbolize_names: true)
65
+ end.wait
66
+ end
67
+
68
+ # Close the WebSocket connection.
69
+ #
70
+ # @return [self]
71
+ def close
72
+ return self unless @connected
73
+
74
+ @connection&.close
75
+ @connected = false
76
+ @connection = nil
77
+
78
+ self
79
+ end
80
+
81
+ # Check if the transport is connected.
82
+ #
83
+ # @return [Boolean] true if connected
84
+ def connected?
85
+ @connected
86
+ end
87
+
88
+ private
89
+
90
+ def send_initialize
91
+ send_request(
92
+ jsonrpc: "2.0",
93
+ id: 0,
94
+ method: "initialize",
95
+ params: {
96
+ protocolVersion: "2024-11-05",
97
+ capabilities: {},
98
+ clientInfo: {
99
+ name: "RobotLab",
100
+ version: RobotLab::VERSION
101
+ }
102
+ }
103
+ )
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end