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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.github/workflows/deploy-yard-docs.yml +52 -0
- data/CHANGELOG.md +55 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +332 -0
- data/Rakefile +67 -0
- data/docs/api/adapters/anthropic.md +121 -0
- data/docs/api/adapters/gemini.md +133 -0
- data/docs/api/adapters/index.md +104 -0
- data/docs/api/adapters/openai.md +134 -0
- data/docs/api/core/index.md +113 -0
- data/docs/api/core/memory.md +314 -0
- data/docs/api/core/network.md +291 -0
- data/docs/api/core/robot.md +273 -0
- data/docs/api/core/state.md +273 -0
- data/docs/api/core/tool.md +353 -0
- data/docs/api/history/active-record-adapter.md +195 -0
- data/docs/api/history/config.md +191 -0
- data/docs/api/history/index.md +132 -0
- data/docs/api/history/thread-manager.md +144 -0
- data/docs/api/index.md +82 -0
- data/docs/api/mcp/client.md +221 -0
- data/docs/api/mcp/index.md +111 -0
- data/docs/api/mcp/server.md +225 -0
- data/docs/api/mcp/transports.md +264 -0
- data/docs/api/messages/index.md +67 -0
- data/docs/api/messages/text-message.md +102 -0
- data/docs/api/messages/tool-call-message.md +144 -0
- data/docs/api/messages/tool-result-message.md +154 -0
- data/docs/api/messages/user-message.md +171 -0
- data/docs/api/streaming/context.md +174 -0
- data/docs/api/streaming/events.md +237 -0
- data/docs/api/streaming/index.md +108 -0
- data/docs/architecture/core-concepts.md +243 -0
- data/docs/architecture/index.md +138 -0
- data/docs/architecture/message-flow.md +320 -0
- data/docs/architecture/network-orchestration.md +216 -0
- data/docs/architecture/robot-execution.md +243 -0
- data/docs/architecture/state-management.md +323 -0
- data/docs/assets/css/custom.css +56 -0
- data/docs/assets/images/robot_lab.jpg +0 -0
- data/docs/concepts.md +216 -0
- data/docs/examples/basic-chat.md +193 -0
- data/docs/examples/index.md +129 -0
- data/docs/examples/mcp-server.md +290 -0
- data/docs/examples/multi-robot-network.md +312 -0
- data/docs/examples/rails-application.md +420 -0
- data/docs/examples/tool-usage.md +310 -0
- data/docs/getting-started/configuration.md +230 -0
- data/docs/getting-started/index.md +56 -0
- data/docs/getting-started/installation.md +179 -0
- data/docs/getting-started/quick-start.md +203 -0
- data/docs/guides/building-robots.md +376 -0
- data/docs/guides/creating-networks.md +366 -0
- data/docs/guides/history.md +359 -0
- data/docs/guides/index.md +68 -0
- data/docs/guides/mcp-integration.md +356 -0
- data/docs/guides/memory.md +309 -0
- data/docs/guides/rails-integration.md +432 -0
- data/docs/guides/streaming.md +314 -0
- data/docs/guides/using-tools.md +394 -0
- data/docs/index.md +160 -0
- data/examples/01_simple_robot.rb +38 -0
- data/examples/02_tools.rb +106 -0
- data/examples/03_network.rb +103 -0
- data/examples/04_mcp.rb +219 -0
- data/examples/05_streaming.rb +124 -0
- data/examples/06_prompt_templates.rb +324 -0
- data/examples/07_network_memory.rb +329 -0
- data/examples/prompts/assistant/system.txt.erb +2 -0
- data/examples/prompts/assistant/user.txt.erb +1 -0
- data/examples/prompts/billing/system.txt.erb +7 -0
- data/examples/prompts/billing/user.txt.erb +1 -0
- data/examples/prompts/classifier/system.txt.erb +4 -0
- data/examples/prompts/classifier/user.txt.erb +1 -0
- data/examples/prompts/entity_extractor/system.txt.erb +11 -0
- data/examples/prompts/entity_extractor/user.txt.erb +3 -0
- data/examples/prompts/escalation/system.txt.erb +35 -0
- data/examples/prompts/escalation/user.txt.erb +34 -0
- data/examples/prompts/general/system.txt.erb +4 -0
- data/examples/prompts/general/user.txt.erb +1 -0
- data/examples/prompts/github_assistant/system.txt.erb +6 -0
- data/examples/prompts/github_assistant/user.txt.erb +1 -0
- data/examples/prompts/helper/system.txt.erb +1 -0
- data/examples/prompts/helper/user.txt.erb +1 -0
- data/examples/prompts/keyword_extractor/system.txt.erb +8 -0
- data/examples/prompts/keyword_extractor/user.txt.erb +3 -0
- data/examples/prompts/order_support/system.txt.erb +27 -0
- data/examples/prompts/order_support/user.txt.erb +22 -0
- data/examples/prompts/product_support/system.txt.erb +30 -0
- data/examples/prompts/product_support/user.txt.erb +32 -0
- data/examples/prompts/sentiment_analyzer/system.txt.erb +9 -0
- data/examples/prompts/sentiment_analyzer/user.txt.erb +3 -0
- data/examples/prompts/synthesizer/system.txt.erb +14 -0
- data/examples/prompts/synthesizer/user.txt.erb +15 -0
- data/examples/prompts/technical/system.txt.erb +7 -0
- data/examples/prompts/technical/user.txt.erb +1 -0
- data/examples/prompts/triage/system.txt.erb +16 -0
- data/examples/prompts/triage/user.txt.erb +17 -0
- data/lib/generators/robot_lab/install_generator.rb +78 -0
- data/lib/generators/robot_lab/robot_generator.rb +55 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +41 -0
- data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/result_model.rb.tt +52 -0
- data/lib/generators/robot_lab/templates/robot.rb.tt +46 -0
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +53 -0
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +40 -0
- data/lib/robot_lab/adapters/anthropic.rb +163 -0
- data/lib/robot_lab/adapters/base.rb +85 -0
- data/lib/robot_lab/adapters/gemini.rb +193 -0
- data/lib/robot_lab/adapters/openai.rb +159 -0
- data/lib/robot_lab/adapters/registry.rb +81 -0
- data/lib/robot_lab/configuration.rb +143 -0
- data/lib/robot_lab/error.rb +32 -0
- data/lib/robot_lab/errors.rb +70 -0
- data/lib/robot_lab/history/active_record_adapter.rb +146 -0
- data/lib/robot_lab/history/config.rb +115 -0
- data/lib/robot_lab/history/thread_manager.rb +93 -0
- data/lib/robot_lab/mcp/client.rb +210 -0
- data/lib/robot_lab/mcp/server.rb +84 -0
- data/lib/robot_lab/mcp/transports/base.rb +56 -0
- data/lib/robot_lab/mcp/transports/sse.rb +117 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +133 -0
- data/lib/robot_lab/mcp/transports/streamable_http.rb +139 -0
- data/lib/robot_lab/mcp/transports/websocket.rb +108 -0
- data/lib/robot_lab/memory.rb +882 -0
- data/lib/robot_lab/memory_change.rb +123 -0
- data/lib/robot_lab/message.rb +357 -0
- data/lib/robot_lab/network.rb +350 -0
- data/lib/robot_lab/rails/engine.rb +29 -0
- data/lib/robot_lab/rails/railtie.rb +42 -0
- data/lib/robot_lab/robot.rb +560 -0
- data/lib/robot_lab/robot_result.rb +205 -0
- data/lib/robot_lab/robotic_model.rb +324 -0
- data/lib/robot_lab/state_proxy.rb +188 -0
- data/lib/robot_lab/streaming/context.rb +144 -0
- data/lib/robot_lab/streaming/events.rb +95 -0
- data/lib/robot_lab/streaming/sequence_counter.rb +48 -0
- data/lib/robot_lab/task.rb +117 -0
- data/lib/robot_lab/tool.rb +223 -0
- data/lib/robot_lab/tool_config.rb +112 -0
- data/lib/robot_lab/tool_manifest.rb +234 -0
- data/lib/robot_lab/user_message.rb +118 -0
- data/lib/robot_lab/version.rb +5 -0
- data/lib/robot_lab/waiter.rb +73 -0
- data/lib/robot_lab.rb +195 -0
- data/mkdocs.yml +214 -0
- data/sig/robot_lab.rbs +4 -0
- 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
|