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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ # Error serialization utilities
5
+ #
6
+ # Provides methods to serialize Ruby exceptions into a format
7
+ # suitable for tool results and logging.
8
+ #
9
+ module Errors
10
+ class << self
11
+ # Serialize an exception to a hash
12
+ #
13
+ # @param error [Exception] The error to serialize
14
+ # @param include_backtrace [Boolean] Whether to include backtrace
15
+ # @return [Hash] Serialized error
16
+ #
17
+ def serialize(error, include_backtrace: false)
18
+ result = {
19
+ type: error.class.name,
20
+ message: error.message
21
+ }
22
+
23
+ if include_backtrace && error.backtrace
24
+ result[:backtrace] = error.backtrace.first(10)
25
+ end
26
+
27
+ if error.cause
28
+ result[:cause] = serialize(error.cause, include_backtrace: include_backtrace)
29
+ end
30
+
31
+ result
32
+ end
33
+
34
+ # Deserialize an error hash back to an exception
35
+ #
36
+ # @param hash [Hash] Serialized error
37
+ # @return [StandardError]
38
+ #
39
+ def deserialize(hash)
40
+ hash = hash.transform_keys(&:to_sym)
41
+ klass = begin
42
+ Object.const_get(hash[:type])
43
+ rescue NameError
44
+ StandardError
45
+ end
46
+ klass.new(hash[:message])
47
+ end
48
+
49
+ # Format error for display
50
+ #
51
+ # @param error [Exception] The error
52
+ # @return [String]
53
+ #
54
+ def format(error)
55
+ "[#{error.class.name}] #{error.message}"
56
+ end
57
+
58
+ # Wrap a block and return error hash on failure
59
+ #
60
+ # @yield Block to execute
61
+ # @return [Hash] { data: result } or { error: serialized_error }
62
+ #
63
+ def capture
64
+ { data: yield }
65
+ rescue StandardError => e
66
+ { error: serialize(e) }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module History
5
+ # ActiveRecord-based history persistence adapter
6
+ #
7
+ # Provides thread and result storage using ActiveRecord models.
8
+ # Requires Rails or standalone ActiveRecord setup.
9
+ #
10
+ # @example
11
+ # adapter = ActiveRecordAdapter.new(
12
+ # thread_model: RobotLabThread,
13
+ # result_model: RobotLabResult
14
+ # )
15
+ #
16
+ # config = adapter.to_config
17
+ # network = RobotLab.create_network(history: config)
18
+ #
19
+ class ActiveRecordAdapter
20
+ # @!attribute [r] thread_model
21
+ # @return [Class] ActiveRecord model class for threads
22
+ # @!attribute [r] result_model
23
+ # @return [Class] ActiveRecord model class for results
24
+ attr_reader :thread_model, :result_model
25
+
26
+ # Initialize adapter with ActiveRecord models
27
+ #
28
+ # @param thread_model [Class] ActiveRecord model for threads
29
+ # @param result_model [Class] ActiveRecord model for results
30
+ #
31
+ def initialize(thread_model:, result_model:)
32
+ @thread_model = thread_model
33
+ @result_model = result_model
34
+ end
35
+
36
+ # Create a new thread
37
+ #
38
+ # @param state [State] Current state
39
+ # @param input [String, UserMessage] Initial input
40
+ # @return [Hash] Thread ID and metadata
41
+ #
42
+ def create_thread(state:, input:, **)
43
+ input_content = input.is_a?(UserMessage) ? input.content : input.to_s
44
+ input_metadata = input.is_a?(UserMessage) ? input.metadata : {}
45
+
46
+ thread = @thread_model.create!(
47
+ session_id: SecureRandom.uuid,
48
+ initial_input: input_content,
49
+ input_metadata: input_metadata,
50
+ state_data: state.data.to_h
51
+ )
52
+
53
+ { session_id: thread.session_id, created_at: thread.created_at }
54
+ end
55
+
56
+ # Retrieve results for a thread
57
+ #
58
+ # @param session_id [String] Thread identifier
59
+ # @return [Array<RobotResult>] History of results
60
+ #
61
+ def get(session_id:, **)
62
+ @result_model
63
+ .where(session_id: session_id)
64
+ .order(:sequence_number, :created_at)
65
+ .map { |record| deserialize_result(record) }
66
+ end
67
+
68
+ # Append user message to thread
69
+ #
70
+ # @param session_id [String] Thread identifier
71
+ # @param message [UserMessage] Message to append
72
+ #
73
+ def append_user_message(session_id:, message:, **)
74
+ @thread_model.where(session_id: session_id).update_all(
75
+ last_user_message: message.content,
76
+ last_user_message_at: Time.current
77
+ )
78
+ end
79
+
80
+ # Append results to thread
81
+ #
82
+ # @param session_id [String] Thread identifier
83
+ # @param new_results [Array<RobotResult>] Results to append
84
+ #
85
+ def append_results(session_id:, new_results:, **)
86
+ base_sequence = @result_model.where(session_id: session_id).maximum(:sequence_number) || 0
87
+
88
+ new_results.each_with_index do |result, index|
89
+ @result_model.create!(
90
+ session_id: session_id,
91
+ robot_name: result.robot_name,
92
+ sequence_number: base_sequence + index + 1,
93
+ output_data: serialize_messages(result.output),
94
+ tool_calls_data: serialize_messages(result.tool_calls),
95
+ stop_reason: result.stop_reason,
96
+ checksum: result.checksum
97
+ )
98
+ end
99
+
100
+ # Update thread timestamp
101
+ @thread_model.where(session_id: session_id).update_all(
102
+ updated_at: Time.current
103
+ )
104
+ end
105
+
106
+ # Convert adapter to Config object
107
+ #
108
+ # @return [Config] History configuration
109
+ #
110
+ def to_config
111
+ Config.new(
112
+ create_thread: method(:create_thread),
113
+ get: method(:get),
114
+ append_user_message: method(:append_user_message),
115
+ append_results: method(:append_results)
116
+ )
117
+ end
118
+
119
+ private
120
+
121
+ def serialize_messages(messages)
122
+ messages.map(&:to_h)
123
+ end
124
+
125
+ def deserialize_result(record)
126
+ output = deserialize_messages(record.output_data)
127
+ tool_calls = deserialize_messages(record.tool_calls_data)
128
+
129
+ RobotResult.new(
130
+ robot_name: record.robot_name,
131
+ output: output,
132
+ tool_calls: tool_calls,
133
+ stop_reason: record.stop_reason
134
+ )
135
+ end
136
+
137
+ def deserialize_messages(data)
138
+ return [] unless data
139
+
140
+ data.map do |msg_hash|
141
+ Message.from_hash(msg_hash.symbolize_keys)
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module History
5
+ # Configuration for conversation history persistence
6
+ #
7
+ # Defines callbacks for creating threads, retrieving history,
8
+ # and appending messages/results.
9
+ #
10
+ # @example
11
+ # config = History::Config.new(
12
+ # create_thread: ->(state:, input:, **) {
13
+ # { session_id: SecureRandom.uuid }
14
+ # },
15
+ # get: ->(session_id:, **) {
16
+ # database.find_results(session_id)
17
+ # },
18
+ # append_results: ->(session_id:, new_results:, **) {
19
+ # database.insert_results(session_id, new_results)
20
+ # }
21
+ # )
22
+ #
23
+ class Config
24
+ # @!attribute [rw] create_thread
25
+ # @return [Proc, nil] callback to create a new conversation thread
26
+ # @!attribute [rw] get
27
+ # @return [Proc, nil] callback to retrieve history for a thread
28
+ # @!attribute [rw] append_user_message
29
+ # @return [Proc, nil] callback to append user messages
30
+ # @!attribute [rw] append_results
31
+ # @return [Proc, nil] callback to append robot results
32
+ attr_accessor :create_thread, :get, :append_user_message, :append_results
33
+
34
+ # Initialize history configuration
35
+ #
36
+ # @param create_thread [Proc] Callback to create a new thread
37
+ # @param get [Proc] Callback to retrieve history for a thread
38
+ # @param append_user_message [Proc] Callback to append user messages
39
+ # @param append_results [Proc] Callback to append robot results
40
+ #
41
+ def initialize(create_thread: nil, get: nil, append_user_message: nil, append_results: nil)
42
+ @create_thread = create_thread
43
+ @get = get
44
+ @append_user_message = append_user_message
45
+ @append_results = append_results
46
+ end
47
+
48
+ # Check if history persistence is configured
49
+ #
50
+ # @return [Boolean]
51
+ #
52
+ def configured?
53
+ @create_thread && @get
54
+ end
55
+
56
+ # Create a new conversation thread
57
+ #
58
+ # @param state [State] Current state
59
+ # @param input [String, UserMessage] Initial input
60
+ # @param kwargs [Hash] Additional arguments
61
+ # @return [Hash] Must include :session_id
62
+ #
63
+ def create_thread!(state:, input:, **kwargs)
64
+ raise HistoryError, "create_thread callback not configured" unless @create_thread
65
+
66
+ result = @create_thread.call(state: state, input: input, **kwargs)
67
+
68
+ unless result.is_a?(Hash) && result[:session_id]
69
+ raise HistoryError, "create_thread must return a hash with :session_id"
70
+ end
71
+
72
+ result
73
+ end
74
+
75
+ # Retrieve history for a thread
76
+ #
77
+ # @param session_id [String] Thread identifier
78
+ # @param kwargs [Hash] Additional arguments
79
+ # @return [Array<RobotResult>] History of results
80
+ #
81
+ def get!(session_id:, **kwargs)
82
+ raise HistoryError, "get callback not configured" unless @get
83
+
84
+ @get.call(session_id: session_id, **kwargs)
85
+ end
86
+
87
+ # Append a user message to the thread
88
+ #
89
+ # @param session_id [String] Thread identifier
90
+ # @param message [UserMessage] Message to append
91
+ # @param kwargs [Hash] Additional arguments
92
+ #
93
+ def append_user_message!(session_id:, message:, **kwargs)
94
+ return unless @append_user_message
95
+
96
+ @append_user_message.call(session_id: session_id, message: message, **kwargs)
97
+ end
98
+
99
+ # Append robot results to the thread
100
+ #
101
+ # @param session_id [String] Thread identifier
102
+ # @param new_results [Array<RobotResult>] Results to append
103
+ # @param kwargs [Hash] Additional arguments
104
+ #
105
+ def append_results!(session_id:, new_results:, **kwargs)
106
+ return unless @append_results
107
+
108
+ @append_results.call(session_id: session_id, new_results: new_results, **kwargs)
109
+ end
110
+ end
111
+
112
+ # Error raised when history operations fail
113
+ class HistoryError < RobotLab::Error; end
114
+ end
115
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module History
5
+ # Manages conversation thread lifecycle
6
+ #
7
+ # Handles thread creation, history retrieval, and result persistence
8
+ # using the configured history adapter.
9
+ #
10
+ # @example
11
+ # manager = ThreadManager.new(config)
12
+ # session_id = manager.create_thread(state: state, input: "Hello")
13
+ # history = manager.get_history(session_id)
14
+ #
15
+ class ThreadManager
16
+ # @!attribute [r] config
17
+ # @return [Config] the history configuration
18
+ attr_reader :config
19
+
20
+ # Initialize thread manager
21
+ #
22
+ # @param config [Config] History configuration
23
+ #
24
+ def initialize(config)
25
+ @config = config
26
+ end
27
+
28
+ # Create a new conversation thread
29
+ #
30
+ # @param state [State] Current state
31
+ # @param input [String, UserMessage] Initial input
32
+ # @return [String] Thread ID
33
+ #
34
+ def create_thread(state:, input:)
35
+ result = @config.create_thread!(state: state, input: input)
36
+ result[:session_id]
37
+ end
38
+
39
+ # Get history for a thread
40
+ #
41
+ # @param session_id [String] Thread identifier
42
+ # @return [Array<RobotResult>] History of results
43
+ #
44
+ def get_history(session_id)
45
+ @config.get!(session_id: session_id)
46
+ end
47
+
48
+ # Append user message to thread
49
+ #
50
+ # @param session_id [String] Thread identifier
51
+ # @param message [UserMessage] Message to append
52
+ #
53
+ def append_user_message(session_id:, message:)
54
+ @config.append_user_message!(session_id: session_id, message: message)
55
+ end
56
+
57
+ # Append results to thread
58
+ #
59
+ # @param session_id [String] Thread identifier
60
+ # @param results [Array<RobotResult>] Results to append
61
+ #
62
+ def append_results(session_id:, results:)
63
+ @config.append_results!(session_id: session_id, new_results: results)
64
+ end
65
+
66
+ # Load state from thread history
67
+ #
68
+ # @param session_id [String] Thread identifier
69
+ # @param state [State, Memory] State/Memory to populate
70
+ # @return [State, Memory] State/Memory with loaded history
71
+ #
72
+ def load_state(session_id:, state:)
73
+ results = get_history(session_id)
74
+
75
+ state.session_id = session_id
76
+ results.each { |r| state.append_result(r) }
77
+
78
+ state
79
+ end
80
+
81
+ # Save state results to thread
82
+ #
83
+ # @param session_id [String] Thread identifier
84
+ # @param state [State] State with results to save
85
+ # @param since_index [Integer] Save results from this index
86
+ #
87
+ def save_state(session_id:, state:, since_index: 0)
88
+ new_results = state.results[since_index..]
89
+ append_results(session_id: session_id, results: new_results) if new_results.any?
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,210 @@
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
+ class Client
17
+ # @!attribute [r] server
18
+ # @return [Server] the MCP server configuration
19
+ # @!attribute [r] connected
20
+ # @return [Boolean] whether currently connected
21
+ attr_reader :server, :connected
22
+
23
+ # Creates a new MCP Client instance.
24
+ #
25
+ # @param server_or_config [Server, Hash] the server or configuration hash
26
+ # @raise [ArgumentError] if config is invalid
27
+ def initialize(server_or_config)
28
+ @server = case server_or_config
29
+ when Server
30
+ server_or_config
31
+ when Hash
32
+ Server.new(**server_or_config.transform_keys(&:to_sym))
33
+ else
34
+ raise ArgumentError, "Invalid server config"
35
+ end
36
+ @connected = false
37
+ @transport = nil
38
+ @request_id = 0
39
+ end
40
+
41
+ # Connect to the MCP server
42
+ #
43
+ # @return [self]
44
+ #
45
+ def connect
46
+ return self if @connected
47
+
48
+ @transport = create_transport
49
+ @transport.connect if @transport.respond_to?(:connect)
50
+ @connected = true
51
+
52
+ self
53
+ rescue StandardError => e
54
+ RobotLab.configuration.logger.warn("MCP connection failed for #{@server.name}: #{e.message}")
55
+ @connected = false
56
+ self
57
+ end
58
+
59
+ # Disconnect from the server
60
+ #
61
+ # @return [self]
62
+ #
63
+ def disconnect
64
+ return self unless @connected
65
+
66
+ @transport.close if @transport.respond_to?(:close)
67
+ @connected = false
68
+ @transport = nil
69
+
70
+ self
71
+ end
72
+
73
+ # List available tools from the server
74
+ #
75
+ # @return [Array<Hash>] Tool definitions
76
+ #
77
+ def list_tools
78
+ ensure_connected!
79
+ response = request(method: "tools/list")
80
+ response[:tools] || []
81
+ end
82
+
83
+ # Call a tool on the server
84
+ #
85
+ # @param name [String] Tool name
86
+ # @param arguments [Hash] Tool arguments
87
+ # @return [Object] Tool result
88
+ #
89
+ def call_tool(name, arguments = {})
90
+ ensure_connected!
91
+ response = request(
92
+ method: "tools/call",
93
+ params: { name: name, arguments: arguments }
94
+ )
95
+ response[:content] || response
96
+ end
97
+
98
+ # List available resources
99
+ #
100
+ # @return [Array<Hash>]
101
+ #
102
+ def list_resources
103
+ ensure_connected!
104
+ response = request(method: "resources/list")
105
+ response[:resources] || []
106
+ end
107
+
108
+ # Read a resource
109
+ #
110
+ # @param uri [String] Resource URI
111
+ # @return [Object]
112
+ #
113
+ def read_resource(uri)
114
+ ensure_connected!
115
+ response = request(method: "resources/read", params: { uri: uri })
116
+ response[:contents] || response
117
+ end
118
+
119
+ # List available prompts
120
+ #
121
+ # @return [Array<Hash>]
122
+ #
123
+ def list_prompts
124
+ ensure_connected!
125
+ response = request(method: "prompts/list")
126
+ response[:prompts] || []
127
+ end
128
+
129
+ # Get a prompt
130
+ #
131
+ # @param name [String] Prompt name
132
+ # @param arguments [Hash] Prompt arguments
133
+ # @return [Hash]
134
+ #
135
+ def get_prompt(name, arguments = {})
136
+ ensure_connected!
137
+ response = request(method: "prompts/get", params: { name: name, arguments: arguments })
138
+ response
139
+ end
140
+
141
+ # Checks if the client is connected to the server.
142
+ #
143
+ # @return [Boolean]
144
+ def connected?
145
+ @connected
146
+ end
147
+
148
+ # Converts the client to a hash representation.
149
+ #
150
+ # @return [Hash]
151
+ def to_h
152
+ {
153
+ server: @server.to_h,
154
+ connected: @connected
155
+ }
156
+ end
157
+
158
+ private
159
+
160
+ def ensure_connected!
161
+ raise MCPError, "Not connected to MCP server: #{@server.name}" unless @connected
162
+ end
163
+
164
+ def create_transport
165
+ case @server.transport_type
166
+ when "stdio"
167
+ Transports::Stdio.new(@server.transport)
168
+ when "ws", "websocket"
169
+ Transports::WebSocket.new(@server.transport)
170
+ when "sse"
171
+ Transports::SSE.new(@server.transport)
172
+ when "streamable-http", "http"
173
+ Transports::StreamableHTTP.new(@server.transport)
174
+ else
175
+ raise MCPError, "Unsupported transport type: #{@server.transport_type}"
176
+ end
177
+ end
178
+
179
+ def request(method:, params: nil)
180
+ @request_id += 1
181
+
182
+ message = {
183
+ jsonrpc: "2.0",
184
+ id: @request_id,
185
+ method: method
186
+ }
187
+ message[:params] = params if params
188
+
189
+ response = @transport.send_request(message)
190
+ parse_response(response)
191
+ end
192
+
193
+ def parse_response(response)
194
+ return response[:result] if response.is_a?(Hash) && response[:result]
195
+
196
+ case response
197
+ when String
198
+ parsed = JSON.parse(response, symbolize_names: true)
199
+ parsed[:result] || parsed
200
+ when Hash
201
+ response[:result] || response
202
+ else
203
+ response
204
+ end
205
+ rescue JSON::ParserError
206
+ { raw: response }
207
+ end
208
+ end
209
+ end
210
+ end