robot_lab 0.2.1 → 0.2.7

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 (236) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/.loki +60 -0
  4. data/.quality/reek_baseline.txt +43 -0
  5. data/.rubocop.yml +5 -167
  6. data/CHANGELOG.md +54 -0
  7. data/README.md +185 -64
  8. data/Rakefile +28 -12
  9. data/docs/api/core/index.md +41 -14
  10. data/docs/api/core/memory.md +247 -29
  11. data/docs/api/core/network.md +285 -33
  12. data/docs/api/core/result.md +120 -32
  13. data/docs/api/core/robot.md +636 -60
  14. data/docs/api/core/state.md +87 -197
  15. data/docs/api/core/tool.md +165 -20
  16. data/docs/api/errors.md +152 -14
  17. data/docs/api/hooks.md +469 -0
  18. data/docs/api/index.md +83 -7
  19. data/docs/api/mcp/client.md +129 -35
  20. data/docs/api/mcp/index.md +164 -23
  21. data/docs/api/mcp/server.md +27 -3
  22. data/docs/api/mcp/transports.md +94 -22
  23. data/docs/api/messages/index.md +26 -3
  24. data/docs/api/messages/text-message.md +33 -11
  25. data/docs/api/messages/tool-call-message.md +27 -4
  26. data/docs/api/messages/tool-result-message.md +23 -4
  27. data/docs/api/messages/user-message.md +45 -8
  28. data/docs/api/skills.md +519 -0
  29. data/docs/api/streaming/context.md +28 -5
  30. data/docs/api/streaming/index.md +57 -11
  31. data/docs/api/support.md +846 -0
  32. data/docs/architecture/core-concepts.md +118 -30
  33. data/docs/architecture/index.md +86 -11
  34. data/docs/architecture/message-flow.md +66 -29
  35. data/docs/architecture/network-orchestration.md +153 -38
  36. data/docs/architecture/robot-execution.md +173 -91
  37. data/docs/architecture/state-management.md +31 -12
  38. data/docs/concepts.md +176 -21
  39. data/docs/examples/basic-chat.md +72 -19
  40. data/docs/examples/index.md +117 -31
  41. data/docs/examples/mcp-server.md +154 -45
  42. data/docs/examples/multi-robot-network.md +91 -21
  43. data/docs/examples/tool-usage.md +104 -37
  44. data/docs/getting-started/configuration.md +299 -93
  45. data/docs/getting-started/installation.md +53 -41
  46. data/docs/getting-started/quick-start.md +51 -6
  47. data/docs/guides/building-robots.md +296 -50
  48. data/docs/guides/creating-networks.md +220 -31
  49. data/docs/guides/hooks.md +1084 -0
  50. data/docs/guides/index.md +5 -0
  51. data/docs/guides/knowledge.md +37 -6
  52. data/docs/guides/mcp-integration.md +211 -44
  53. data/docs/guides/memory.md +103 -12
  54. data/docs/guides/observability.md +166 -51
  55. data/docs/guides/streaming.md +184 -125
  56. data/docs/guides/using-tools.md +300 -11
  57. data/docs/index.md +37 -5
  58. data/examples/01_simple_robot.rb +2 -2
  59. data/examples/02_tools.rb +14 -4
  60. data/examples/03_network.rb +23 -9
  61. data/examples/04_mcp.rb +11 -4
  62. data/examples/05_streaming.rb +8 -5
  63. data/examples/06_prompt_templates.rb +13 -9
  64. data/examples/07_network_memory.rb +5 -5
  65. data/examples/08_llm_config.rb +20 -15
  66. data/examples/09_chaining.rb +4 -4
  67. data/examples/11_network_introspection.rb +4 -4
  68. data/examples/12_message_bus.rb +2 -2
  69. data/examples/13_spawn.rb +2 -2
  70. data/examples/14_rusty_circuit/README.md +1 -0
  71. data/examples/14_rusty_circuit/comic.rb +7 -3
  72. data/examples/14_rusty_circuit/display.rb +14 -0
  73. data/examples/14_rusty_circuit/heckler.rb +8 -6
  74. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  75. data/examples/14_rusty_circuit/scout.rb +17 -10
  76. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  77. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  78. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  79. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  80. data/examples/16_writers_room/writer.rb +22 -22
  81. data/examples/16_writers_room/writers_room.rb +2 -0
  82. data/examples/17_skills.rb +14 -13
  83. data/examples/18_rails/README.md +20 -1
  84. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  85. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  86. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  87. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  88. data/examples/19_token_tracking.rb +25 -9
  89. data/examples/20_circuit_breaker.rb +10 -7
  90. data/examples/21_learning_loop.rb +42 -16
  91. data/examples/22_context_compression.rb +23 -23
  92. data/examples/23_convergence.rb +24 -17
  93. data/examples/24_structured_delegation.rb +13 -8
  94. data/examples/25_history_search.rb +12 -8
  95. data/examples/27_incident_response/incident_response.rb +31 -13
  96. data/examples/28_mcp_discovery.rb +17 -13
  97. data/examples/29_ractor_tools.rb +4 -2
  98. data/examples/30_ractor_network.rb +22 -17
  99. data/examples/31_launch_assessment.rb +20 -9
  100. data/examples/32_newsletter_reader.rb +7 -2
  101. data/examples/33_stock_predictor.rb +34 -13
  102. data/examples/34_agentskills.rb +7 -3
  103. data/examples/35_hooks.rb +266 -0
  104. data/examples/README.md +203 -42
  105. data/examples/common.rb +79 -7
  106. data/examples/xyzzy.rb +97 -0
  107. data/lib/robot_lab/agent_skill.rb +5 -4
  108. data/lib/robot_lab/budget/ledger.rb +98 -0
  109. data/lib/robot_lab/capabilities.rb +84 -0
  110. data/lib/robot_lab/config/defaults.yml +10 -0
  111. data/lib/robot_lab/config.rb +14 -9
  112. data/lib/robot_lab/error.rb +34 -4
  113. data/lib/robot_lab/errors.rb +45 -0
  114. data/lib/robot_lab/hook.rb +79 -0
  115. data/lib/robot_lab/hook_context.rb +194 -0
  116. data/lib/robot_lab/hook_registry.rb +55 -0
  117. data/lib/robot_lab/hooks.rb +87 -0
  118. data/lib/robot_lab/mcp/connection_poller.rb +2 -2
  119. data/lib/robot_lab/names.rb +402 -0
  120. data/lib/robot_lab/narrator.rb +87 -0
  121. data/lib/robot_lab/network.rb +77 -20
  122. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  123. data/lib/robot_lab/robot/budget.rb +89 -0
  124. data/lib/robot_lab/robot/bus_messaging.rb +90 -18
  125. data/lib/robot_lab/robot/hooking.rb +56 -0
  126. data/lib/robot_lab/robot/mcp_management.rb +8 -2
  127. data/lib/robot_lab/robot/template_rendering.rb +18 -5
  128. data/lib/robot_lab/robot.rb +233 -89
  129. data/lib/robot_lab/run_config.rb +24 -5
  130. data/lib/robot_lab/runnable.rb +51 -0
  131. data/lib/robot_lab/sandbox/null.rb +13 -0
  132. data/lib/robot_lab/sandbox/seatbelt.rb +104 -0
  133. data/lib/robot_lab/sandbox.rb +52 -0
  134. data/lib/robot_lab/script_tool.rb +60 -2
  135. data/lib/robot_lab/task.rb +26 -20
  136. data/lib/robot_lab/tool.rb +52 -11
  137. data/lib/robot_lab/version.rb +1 -1
  138. data/lib/robot_lab.rb +70 -15
  139. data/mkdocs.yml +7 -1
  140. metadata +41 -99
  141. data/examples/temp.md +0 -51
  142. data/site/404.html +0 -2300
  143. data/site/api/core/index.html +0 -2706
  144. data/site/api/core/memory/index.html +0 -3793
  145. data/site/api/core/network/index.html +0 -3500
  146. data/site/api/core/robot/index.html +0 -4566
  147. data/site/api/core/state/index.html +0 -3390
  148. data/site/api/core/tool/index.html +0 -3843
  149. data/site/api/index.html +0 -2635
  150. data/site/api/mcp/client/index.html +0 -3435
  151. data/site/api/mcp/index.html +0 -2783
  152. data/site/api/mcp/server/index.html +0 -3252
  153. data/site/api/mcp/transports/index.html +0 -3352
  154. data/site/api/messages/index.html +0 -2641
  155. data/site/api/messages/text-message/index.html +0 -3087
  156. data/site/api/messages/tool-call-message/index.html +0 -3159
  157. data/site/api/messages/tool-result-message/index.html +0 -3252
  158. data/site/api/messages/user-message/index.html +0 -3212
  159. data/site/api/streaming/context/index.html +0 -3282
  160. data/site/api/streaming/events/index.html +0 -3347
  161. data/site/api/streaming/index.html +0 -2738
  162. data/site/architecture/core-concepts/index.html +0 -3757
  163. data/site/architecture/index.html +0 -2797
  164. data/site/architecture/message-flow/index.html +0 -3238
  165. data/site/architecture/network-orchestration/index.html +0 -3433
  166. data/site/architecture/robot-execution/index.html +0 -3140
  167. data/site/architecture/state-management/index.html +0 -3498
  168. data/site/assets/css/custom.css +0 -56
  169. data/site/assets/images/favicon.png +0 -0
  170. data/site/assets/images/robot_lab.jpg +0 -0
  171. data/site/assets/javascripts/bundle.79ae519e.min.js +0 -16
  172. data/site/assets/javascripts/bundle.79ae519e.min.js.map +0 -7
  173. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -1
  174. data/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -18
  175. data/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -18
  176. data/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -18
  177. data/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -1
  178. data/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -18
  179. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -18
  180. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -18
  181. data/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -1
  182. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -1
  183. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -18
  184. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -1
  185. data/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -18
  186. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -1
  187. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -1
  188. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -1
  189. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -1
  190. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -1
  191. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -18
  192. data/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -18
  193. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -18
  194. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -18
  195. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -18
  196. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -1
  197. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -1
  198. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -18
  199. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -1
  200. data/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -1
  201. data/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -1
  202. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -18
  203. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -1
  204. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -1
  205. data/site/assets/javascripts/lunr/tinyseg.js +0 -206
  206. data/site/assets/javascripts/lunr/wordcut.js +0 -6708
  207. data/site/assets/javascripts/workers/search.2c215733.min.js +0 -42
  208. data/site/assets/javascripts/workers/search.2c215733.min.js.map +0 -7
  209. data/site/assets/stylesheets/main.484c7ddc.min.css +0 -1
  210. data/site/assets/stylesheets/main.484c7ddc.min.css.map +0 -1
  211. data/site/assets/stylesheets/palette.ab4e12ef.min.css +0 -1
  212. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +0 -1
  213. data/site/concepts/index.html +0 -3455
  214. data/site/examples/basic-chat/index.html +0 -2880
  215. data/site/examples/index.html +0 -2907
  216. data/site/examples/mcp-server/index.html +0 -3018
  217. data/site/examples/multi-robot-network/index.html +0 -3131
  218. data/site/examples/rails-application/index.html +0 -3329
  219. data/site/examples/tool-usage/index.html +0 -3085
  220. data/site/getting-started/configuration/index.html +0 -3745
  221. data/site/getting-started/index.html +0 -2572
  222. data/site/getting-started/installation/index.html +0 -2981
  223. data/site/getting-started/quick-start/index.html +0 -2942
  224. data/site/guides/building-robots/index.html +0 -4290
  225. data/site/guides/creating-networks/index.html +0 -3858
  226. data/site/guides/index.html +0 -2586
  227. data/site/guides/mcp-integration/index.html +0 -3581
  228. data/site/guides/memory/index.html +0 -3586
  229. data/site/guides/rails-integration/index.html +0 -4019
  230. data/site/guides/streaming/index.html +0 -3157
  231. data/site/guides/using-tools/index.html +0 -3802
  232. data/site/index.html +0 -2671
  233. data/site/search/search_index.json +0 -1
  234. data/site/sitemap.xml +0 -183
  235. data/site/sitemap.xml.gz +0 -0
  236. data/site/tags.json +0 -1
@@ -6,8 +6,10 @@ Connects to MCP servers, discovers tools, and invokes them via the Model Context
6
6
 
7
7
  ```ruby
8
8
  client = RobotLab::MCP::Client.new(
9
- name: "filesystem",
10
- transport: { type: "stdio", command: "mcp-server-filesystem", args: ["--root", "/data"] }
9
+ {
10
+ name: "filesystem",
11
+ transport: { type: "stdio", command: "mcp-server-filesystem", args: ["--root", "/data"] }
12
+ }
11
13
  )
12
14
 
13
15
  client.connect
@@ -19,16 +21,22 @@ client.disconnect
19
21
  ## Constructor
20
22
 
21
23
  ```ruby
22
- Client.new(server_or_config)
24
+ Client.new(server_or_config, poller: nil)
23
25
  ```
24
26
 
27
+ `server_or_config` is **positional**. Passing the server keys directly —
28
+ `Client.new(name: "fs", transport: {...})` — raises
29
+ `ArgumentError: wrong number of arguments (given 0, expected 1)`. Wrap the
30
+ configuration in braces.
31
+
25
32
  Accepts either a `Server` instance or a Hash configuration. When a Hash is provided, it is used to construct a `Server` internally.
26
33
 
27
34
  **Parameters:**
28
35
 
29
- | Name | Type | Description |
30
- |------|------|-------------|
31
- | `server_or_config` | `Server`, `Hash` | Server instance or configuration hash |
36
+ | Name | Type | Default | Description |
37
+ |------|------|---------|-------------|
38
+ | `server_or_config` | `Server`, `Hash` | **required, positional** | Server instance or configuration hash |
39
+ | `poller` | `MCP::ConnectionPoller`, `nil` | `nil` | Shared `IO.select` poller for multiplexing stdio transports (see [ConnectionPoller](#connectionpoller)) |
32
40
 
33
41
  **Hash Configuration Keys:**
34
42
 
@@ -37,6 +45,7 @@ Accepts either a `Server` instance or a Hash configuration. When a Hash is provi
37
45
  | `name` | `String` | Yes | Server identifier |
38
46
  | `transport` | `Hash` | Yes | Transport configuration (must include `type`) |
39
47
  | `timeout` | `Numeric` | No | Request timeout in seconds (default: 15). Propagated to the transport layer |
48
+ | `description` | `String` | No | Human-readable summary used by `MCP::ServerDiscovery` |
40
49
 
41
50
  **Raises:** `ArgumentError` if the config is neither a `Server` nor a `Hash`.
42
51
 
@@ -50,13 +59,24 @@ client.server # => RobotLab::MCP::Server
50
59
 
51
60
  The MCP server configuration object.
52
61
 
53
- ### connected?
62
+ ### transport
63
+
64
+ ```ruby
65
+ client.transport # => RobotLab::MCP::Transports::Base subclass | nil
66
+ ```
67
+
68
+ The transport instance created by `connect`. `nil` before the first successful
69
+ `connect` and again after `disconnect`.
70
+
71
+ ### connected
54
72
 
55
73
  ```ruby
74
+ client.connected # => Boolean
56
75
  client.connected? # => Boolean
57
76
  ```
58
77
 
59
- Whether the client is currently connected to the server.
78
+ Whether the client is currently connected to the server. `connected` is a plain
79
+ reader over the same ivar that `connected?` returns; both are public.
60
80
 
61
81
  ## Methods
62
82
 
@@ -66,9 +86,9 @@ Whether the client is currently connected to the server.
66
86
  client.connect # => self
67
87
  ```
68
88
 
69
- Establish a connection to the MCP server. Creates the appropriate transport based on the server's transport type, then connects. If already connected, returns immediately.
89
+ Establish a connection to the MCP server. Creates the appropriate transport based on the server's transport type, then connects. If already connected, returns immediately. When a `poller:` was supplied, the client registers itself with the poller after the transport connects.
70
90
 
71
- Connection failures are logged as warnings and the client remains in a disconnected state (does not raise).
91
+ Connection failures are logged as warnings and the client remains in a disconnected state (does not raise). Always check `connected?` afterwards.
72
92
 
73
93
  ### disconnect
74
94
 
@@ -76,7 +96,7 @@ Connection failures are logged as warnings and the client remains in a disconnec
76
96
  client.disconnect # => self
77
97
  ```
78
98
 
79
- Close the connection to the MCP server. Closes the underlying transport and resets connection state. If not connected, returns immediately.
99
+ Close the connection to the MCP server. Unregisters from the poller (if any), closes the underlying transport, and resets `transport` to `nil`. If not connected, returns immediately.
80
100
 
81
101
  ### list_tools
82
102
 
@@ -166,7 +186,62 @@ Get a prompt by name with optional arguments.
166
186
  client.to_h # => Hash
167
187
  ```
168
188
 
169
- Converts the client to a hash representation containing server config and connection status.
189
+ Converts the client to a hash representation containing server config and connection status:
190
+
191
+ ```ruby
192
+ { server: { name: "...", description: "...", transport: { ... }, timeout: 15 },
193
+ connected: false }
194
+ ```
195
+
196
+ ## ConnectionPoller
197
+
198
+ **Class:** `RobotLab::MCP::ConnectionPoller`
199
+
200
+ By default each stdio client blocks on its own `@stdout.gets` inside a
201
+ `Timeout.timeout`. `ConnectionPoller` replaces that with a single background
202
+ thread running one `IO.select` across every registered stdio transport,
203
+ dispatching each JSON-RPC response to the client that is waiting for it. This is
204
+ useful when one robot talks to several local MCP servers.
205
+
206
+ It is **opt-in**: nothing in `Robot` or `Network` creates one. `Robot`'s internal
207
+ MCP setup calls `MCP::Client.new(server_config)` with no poller. You get a poller
208
+ only by wiring it yourself.
209
+
210
+ Async-based transports (SSE, WebSocket, StreamableHTTP) are unaffected — the
211
+ poller silently ignores any client whose transport is not a live `Stdio`.
212
+
213
+ ```ruby
214
+ poller = RobotLab::MCP::ConnectionPoller.new.start
215
+
216
+ client = RobotLab::MCP::Client.new(
217
+ { name: "fs", transport: { type: "stdio", command: "mcp-server-fs" } },
218
+ poller: poller
219
+ )
220
+
221
+ client.connect # registers the transport's stdout with the poller
222
+ client.call_tool("readFile", { path: "/data/readme.txt" })
223
+ client.disconnect # unregisters
224
+
225
+ poller.stop
226
+ ```
227
+
228
+ ### Methods
229
+
230
+ | Method | Description |
231
+ |--------|-------------|
232
+ | `start` | Start the polling thread (named `RobotLab::MCP::ConnectionPoller`). Idempotent; returns `self` |
233
+ | `stop(timeout: 5)` | Stop the thread, cancelling every pending request with an `MCPError`. Waits up to `timeout` seconds for the thread to join. Returns `self` |
234
+ | `register(client)` | Register a client. Non-stdio (or not-yet-connected) clients are silently ignored |
235
+ | `unregister(client)` | Remove a client's IO from the select set |
236
+ | `send_request(client, message, timeout:)` | Write the JSON-RPC message to the client's stdin and block until the poll loop dispatches the response. Raises `MCPError` on timeout or a broken pipe |
237
+ | `running?` | Whether the polling thread is running |
238
+
239
+ `POLL_INTERVAL` is `0.1` seconds — the `IO.select` timeout, and the sleep used
240
+ when no clients are registered.
241
+
242
+ `Client#request` routes through `poller.send_request` only when a poller is
243
+ present **and** the transport is a `Transports::Stdio`; otherwise it calls
244
+ `transport.send_request` directly.
170
245
 
171
246
  ## Transport Configuration
172
247
 
@@ -176,11 +251,13 @@ The transport type is determined by the `type` key in the transport hash of the
176
251
 
177
252
  ```ruby
178
253
  client = RobotLab::MCP::Client.new(
179
- name: "local",
180
- transport: {
181
- type: "stdio",
182
- command: "npx",
183
- args: ["@modelcontextprotocol/server-filesystem", "/path"]
254
+ {
255
+ name: "local",
256
+ transport: {
257
+ type: "stdio",
258
+ command: "npx",
259
+ args: ["@modelcontextprotocol/server-filesystem", "/path"]
260
+ }
184
261
  }
185
262
  )
186
263
  ```
@@ -189,10 +266,12 @@ client = RobotLab::MCP::Client.new(
189
266
 
190
267
  ```ruby
191
268
  client = RobotLab::MCP::Client.new(
192
- name: "remote",
193
- transport: {
194
- type: "ws",
195
- url: "wss://mcp.example.com/ws"
269
+ {
270
+ name: "remote",
271
+ transport: {
272
+ type: "ws",
273
+ url: "wss://mcp.example.com/ws"
274
+ }
196
275
  }
197
276
  )
198
277
  ```
@@ -201,10 +280,12 @@ client = RobotLab::MCP::Client.new(
201
280
 
202
281
  ```ruby
203
282
  client = RobotLab::MCP::Client.new(
204
- name: "streaming",
205
- transport: {
206
- type: "sse",
207
- url: "https://mcp.example.com/sse"
283
+ {
284
+ name: "streaming",
285
+ transport: {
286
+ type: "sse",
287
+ url: "https://mcp.example.com/sse"
288
+ }
208
289
  }
209
290
  )
210
291
  ```
@@ -213,11 +294,13 @@ client = RobotLab::MCP::Client.new(
213
294
 
214
295
  ```ruby
215
296
  client = RobotLab::MCP::Client.new(
216
- name: "http",
217
- transport: {
218
- type: "streamable-http",
219
- url: "https://mcp.example.com/mcp",
220
- session_id: "optional-session-id"
297
+ {
298
+ name: "http",
299
+ transport: {
300
+ type: "streamable-http",
301
+ url: "https://mcp.example.com/mcp",
302
+ session_id: "optional-session-id"
303
+ }
221
304
  }
222
305
  )
223
306
  ```
@@ -228,11 +311,11 @@ client = RobotLab::MCP::Client.new(
228
311
 
229
312
  ```ruby
230
313
  client = RobotLab::MCP::Client.new(
231
- name: "github",
232
- transport: { type: "stdio", command: "mcp-server-github" }
314
+ { name: "github", transport: { type: "stdio", command: "mcp-server-github" } }
233
315
  )
234
316
 
235
317
  client.connect
318
+ raise "could not connect" unless client.connected?
236
319
 
237
320
  # List available tools
238
321
  tools = client.list_tools
@@ -268,16 +351,27 @@ robot = Robot.new(
268
351
  ]
269
352
  )
270
353
 
271
- # MCP tools are automatically discovered and available to the LLM
272
- result = robot.run("Read the contents of /data/config.yml")
354
+ # `Robot#run` defaults to `mcp: :none, tools: :none`. Opt in on every run that
355
+ # should reach the MCP servers: `mcp: :inherit` connects them, `tools: :inherit`
356
+ # forwards the discovered tools to the model.
357
+ result = robot.run("Read the contents of /data/config.yml", mcp: :inherit, tools: :inherit)
273
358
  puts result.last_text_content
274
359
  ```
275
360
 
361
+ MCP connection failures inside a robot are logged and recorded in
362
+ `robot.failed_mcp_server_names`; they never raise out of `run`.
363
+
276
364
  ### Error Handling
277
365
 
366
+ `connect` swallows its own failures, so test `connected?` rather than rescuing
367
+ around it:
368
+
278
369
  ```ruby
370
+ client.connect
371
+
279
372
  begin
280
- client.connect
373
+ raise RobotLab::MCPError, "not connected" unless client.connected?
374
+
281
375
  result = client.call_tool("unknown_tool", {})
282
376
  rescue RobotLab::MCPError => e
283
377
  puts "MCP error: #{e.message}"
@@ -13,10 +13,17 @@ robot = Robot.new(
13
13
  mcp: [
14
14
  {
15
15
  name: "filesystem",
16
- transport: { type: "stdio", command: "npx @modelcontextprotocol/server-filesystem" }
16
+ transport: {
17
+ type: "stdio",
18
+ command: "npx",
19
+ args: ["@modelcontextprotocol/server-filesystem", "/data"]
20
+ }
17
21
  }
18
22
  ]
19
23
  )
24
+
25
+ # MCP is opt-in per run — see "Connecting at Run Time" below.
26
+ result = robot.run("What files are in /data?", mcp: :inherit, tools: :inherit)
20
27
  ```
21
28
 
22
29
  ## Components
@@ -26,6 +33,8 @@ robot = Robot.new(
26
33
  | [Client](client.md) | Connects to MCP servers, lists tools, calls tools |
27
34
  | [Server](server.md) | Server configuration data structure |
28
35
  | [Transports](transports.md) | Communication methods (stdio, WebSocket, SSE, HTTP) |
36
+ | [ConnectionPoller](client.md#connectionpoller) | Optional shared `IO.select` loop multiplexing several stdio transports |
37
+ | `MCP::ServerDiscovery` | Picks the relevant subset of configured servers for a message (see [Server Discovery](#server-discovery)) |
29
38
 
30
39
  ## Quick Start
31
40
 
@@ -42,33 +51,91 @@ robot = Robot.new(
42
51
  ]
43
52
  )
44
53
 
45
- result = robot.run("List my open pull requests")
54
+ result = robot.run("List my open pull requests", mcp: :inherit, tools: :inherit)
46
55
  result.last_text_content
47
56
  ```
48
57
 
49
- ### MCP in Networks
58
+ ### Connecting at Run Time
50
59
 
51
- Robots in a network can inherit MCP servers from the network or define their own:
60
+ `Robot#run` defaults to `mcp: :none, tools: :none`. Those defaults mean "connect
61
+ nothing and send zero tools for this turn", so a bare `robot.run("...")` reaches
62
+ the LLM with **no** MCP servers connected and **no** tools attached, even when
63
+ `mcp:` was supplied at build time.
52
64
 
53
65
  ```ruby
54
- network_mcp = [
55
- { name: "github", transport: { type: "stdio", command: "mcp-server-github" } }
56
- ]
66
+ robot.run("...") # no MCP, no tools
67
+ robot.run("...", tools: :inherit) # attached local tools only
68
+ robot.run("...", mcp: :inherit, tools: :inherit) # connect MCP servers AND send their tools
69
+ ```
57
70
 
58
- robot = Robot.new(
59
- name: "assistant",
60
- template: :assistant,
61
- mcp: :inherit # Use network's MCP servers
62
- )
71
+ `mcp: :inherit` triggers the connection attempt; `tools: :inherit` is what
72
+ actually forwards the discovered MCP tools to the model. Both are needed.
73
+
74
+ !!! warning "Build-time `:inherit` depends on whether there is a parent"
75
+ `resolve_mcp_hierarchy` does not freeze the parent at construction — it
76
+ recomputes it on **every run** as
77
+ `network_config&.mcp || network_parent_config(network)&.mcp || RobotLab.config.mcp`,
78
+ then resolves the build-time value against it, then the runtime value against
79
+ *that*.
80
+
81
+ - For a **standalone** robot the parent is the global default `:none`, so a
82
+ build-time `mcp: :inherit` / `tools: :inherit` collapses to an allowlist
83
+ that matches nothing. Give the standalone robot an explicit array at build
84
+ time and pass `:inherit` at run time.
85
+ - **Inside a network** whose `config:` sets `mcp:`/`tools:`, build-time
86
+ `:inherit` is exactly how a robot opts in to the network's list — see
87
+ [MCP in Networks](#mcp-in-networks) below.
88
+
89
+ `robot.connect_mcp!` connects eagerly, but a later plain `run()` still sends no
90
+ tools. Connection failures are logged and recorded in
91
+ `robot.failed_mcp_server_names` — they are not raised.
92
+
93
+ ### MCP in Networks
94
+
95
+ A robot can inherit its MCP server list from the network's `config:`. This is one
96
+ of only two fields (`mcp` and `tools`) that a network-level `RunConfig`
97
+ propagates to member robots, and only when the robot opts in with `:inherit`:
98
+
99
+ ```ruby
100
+ network = RobotLab.create_network(
101
+ name: "dev",
102
+ config: RobotLab::RunConfig.new(
103
+ mcp: [{ name: "github", transport: { type: "stdio", command: "mcp-server-github" } }]
104
+ )
105
+ ) do |n|
106
+ n.task :assistant,
107
+ Robot.new(name: "assistant", template: :assistant, mcp: :inherit),
108
+ mcp: :inherit, tools: :inherit
109
+ end
63
110
  ```
64
111
 
112
+ Here the build-time `mcp: :inherit` on the robot is correct and necessary: the
113
+ parent is resolved at run time from the network's `config:`, so `:inherit` picks
114
+ up the network's server list rather than the global `:none`.
115
+
116
+ `Network#task` has the same `mcp: :none, tools: :none` defaults as `Robot#run`,
117
+ so the task must opt in as well — otherwise the inherited list collapses to `[]`.
118
+
119
+ LLM fields (`model`, `temperature`, ...) and callbacks are **not** inherited from
120
+ a network config; each robot reads those from its own configuration.
121
+
65
122
  ### Direct Client Usage
66
123
 
124
+ `Client.new` takes the server (or config hash) as a **positional** argument:
125
+
67
126
  ```ruby
68
127
  client = RobotLab::MCP::Client.new(
69
128
  name: "filesystem",
70
129
  transport: { type: "stdio", command: "mcp-server-filesystem", args: ["--root", "/data"] }
71
130
  )
131
+ # ArgumentError: wrong number of arguments (given 0, expected 1)
132
+
133
+ client = RobotLab::MCP::Client.new(
134
+ {
135
+ name: "filesystem",
136
+ transport: { type: "stdio", command: "mcp-server-filesystem", args: ["--root", "/data"] }
137
+ }
138
+ )
72
139
 
73
140
  client.connect
74
141
  tools = client.list_tools
@@ -76,6 +143,34 @@ result = client.call_tool("readFile", { path: "/data/config.yml" })
76
143
  client.disconnect
77
144
  ```
78
145
 
146
+ ### Server Discovery
147
+
148
+ When a robot is built with `mcp_discovery: true`, the configured server list is
149
+ filtered before connecting: `MCP::ServerDiscovery` scores each server's
150
+ `name + description` against the user's message using term-frequency cosine
151
+ similarity and connects only the servers scoring at or above the threshold
152
+ (`DEFAULT_THRESHOLD` = 0.05).
153
+
154
+ ```ruby
155
+ robot = RobotLab.build(
156
+ name: "assistant",
157
+ mcp_discovery: true,
158
+ mcp: [
159
+ { name: "filesystem", description: "Read, write, and search local files",
160
+ transport: { type: "stdio", command: "mcp-server-fs" } },
161
+ { name: "brew", description: "Install and manage macOS packages via Homebrew",
162
+ transport: { type: "stdio", command: "mcp-server-brew" } }
163
+ ]
164
+ )
165
+
166
+ # Connects only the "brew" server for this message
167
+ robot.run("install imagemagick", mcp: :inherit, tools: :inherit)
168
+ ```
169
+
170
+ Discovery falls back to the **full** server list when no server has a
171
+ `description`, when the query is blank, when no server clears the threshold, or
172
+ when the optional `classifier` gem is unavailable.
173
+
79
174
  ## Transport Types
80
175
 
81
176
  | Type | Config Key | Use Case |
@@ -92,33 +187,79 @@ The `mcp:` parameter on a Robot accepts three types of values:
92
187
  | Value | Meaning |
93
188
  |-------|---------|
94
189
  | `:none` | No MCP servers (explicitly disabled) |
95
- | `:inherit` | Use the network's MCP servers |
190
+ | `:inherit` | Resolve against the parent level (network config, then global config) |
96
191
  | `Array<Hash>` | Explicit list of server configurations |
97
192
 
98
- Each server configuration hash requires:
193
+ Each server configuration hash is passed straight to `MCP::Server.new`:
194
+
195
+ | Key | Type | Required | Description |
196
+ |-----|------|----------|-------------|
197
+ | `name` | `String` | Yes | Unique server identifier |
198
+ | `transport` | `Hash` | Yes | Transport configuration (must include `type`) |
199
+ | `timeout` | `Numeric` | No | Request timeout in seconds (default: 15) |
200
+ | `description` | `String` | No | Human-readable summary; the text `ServerDiscovery` scores against |
201
+
202
+ `transport:` must be a **nested hash**. A flat `transport: "stdio"` with sibling
203
+ `command:`/`args:` keys raises internally (`NoMethodError: undefined method
204
+ 'transform_keys' for an instance of String`). `init_mcp_client` rescues it rather
205
+ than re-raising, so the robot still builds — but with zero tools from that
206
+ server. It is not silent: the failure is logged at `warn` through
207
+ `RobotLab.config.logger` (`"Robot '<name>' error connecting to MCP server
208
+ '<server>': ..."`) and the server is recorded in
209
+ `robot.failed_mcp_server_names`. The same holds for an invalid transport type
210
+ reached through the robot path.
99
211
 
100
- | Key | Type | Description |
101
- |-----|------|-------------|
102
- | `name` | `String` | Unique server identifier |
103
- | `transport` | `Hash` | Transport configuration (must include `type`) |
212
+ `MCP::Server#initialize` ends in `**_extra`, so any other key you add is
213
+ accepted and silently discarded — a typo in `timeout` or `description` will not
214
+ raise.
104
215
 
105
216
  ## Error Handling
106
217
 
107
- MCP operations raise `RobotLab::MCPError` when:
218
+ `RobotLab::MCPError` is raised when a request is made without an active
219
+ connection, and by the transports for protocol/I-O failures.
108
220
 
109
- - Connection to a server fails
110
- - A request is made without an active connection
111
- - An unsupported transport type is specified
221
+ **`Client#connect` does not raise.** It rescues every `StandardError` — including
222
+ a failed transport handshake and an unsupported transport type — logs a warning,
223
+ and leaves the client disconnected. Check `client.connected?` after connecting:
112
224
 
113
225
  ```ruby
226
+ client.connect
227
+
228
+ unless client.connected?
229
+ warn "MCP server unavailable"
230
+ return
231
+ end
232
+
114
233
  begin
115
- client.connect
116
234
  client.call_tool("unknown_tool", {})
117
235
  rescue RobotLab::MCPError => e
118
236
  puts "MCP error: #{e.message}"
119
237
  end
120
238
  ```
121
239
 
240
+ An invalid transport type or a missing `command`/`url` raises `ArgumentError`
241
+ from `MCP::Server.new` — and because `MCP::Client.new` builds the server, from
242
+ `Client.new` too:
243
+
244
+ ```ruby
245
+ RobotLab::MCP::Server.new(name: "x", transport: { type: "bogus", command: "z" })
246
+ # ArgumentError: Invalid transport type: bogus. Must be one of:
247
+ # stdio, sse, ws, websocket, streamable-http, http
248
+ ```
249
+
250
+ That `ArgumentError` only reaches you when you construct the server or client
251
+ **directly**. Through the robot path it is rescued like any other connect
252
+ failure — `init_mcp_client` logs it at `warn` and adds the server to
253
+ `failed_mcp_server_names`:
254
+
255
+ ```ruby
256
+ robot = RobotLab.build(name: "t", system_prompt: "hi",
257
+ mcp: [{ name: "x", transport: { type: "bogus", command: "z" } }])
258
+ robot.connect_mcp!
259
+ # WARN -- : Robot 't' error connecting to MCP server 'x': Invalid transport type: bogus...
260
+ robot.failed_mcp_server_names # => ["x"]
261
+ ```
262
+
122
263
  ## See Also
123
264
 
124
265
  - [MCP Client](client.md)
@@ -11,6 +11,7 @@ This is **not** an MCP server implementation -- it is the configuration used by
11
11
  ```ruby
12
12
  server = RobotLab::MCP::Server.new(
13
13
  name: "filesystem",
14
+ description: "Read, write, and search local files and directories",
14
15
  transport: { type: "stdio", command: "mcp-server-filesystem", args: ["--root", "/data"] }
15
16
  )
16
17
 
@@ -25,7 +26,7 @@ server = RobotLab::MCP::Server.new(
25
26
  ## Constructor
26
27
 
27
28
  ```ruby
28
- Server.new(name:, transport:, timeout: nil, **_extra)
29
+ Server.new(name:, transport:, timeout: nil, description: nil, **_extra)
29
30
  ```
30
31
 
31
32
  **Parameters:**
@@ -35,6 +36,10 @@ Server.new(name:, transport:, timeout: nil, **_extra)
35
36
  | `name` | `String` | **required** | Unique server identifier |
36
37
  | `transport` | `Hash` | **required** | Transport configuration (must include `type`) |
37
38
  | `timeout` | `Numeric`, `nil` | `15` | Request timeout in seconds. Values >= 1000 are auto-converted from milliseconds. Minimum 1 second |
39
+ | `description` | `String`, `nil` | `""` | Human-readable summary of what the server does; scored by `MCP::ServerDiscovery` |
40
+
41
+ Any other keyword is absorbed by `**_extra` and silently discarded, so a
42
+ misspelled key raises nothing.
38
43
 
39
44
  **Raises:** `ArgumentError` if:
40
45
  - The transport type is not one of the valid types
@@ -61,6 +66,18 @@ server.name # => String
61
66
 
62
67
  The server identifier string.
63
68
 
69
+ ### description
70
+
71
+ ```ruby
72
+ server.description # => String
73
+ ```
74
+
75
+ Human-readable summary of the server's capabilities. Always a String — a `nil`
76
+ or omitted `description:` becomes `""`. Used by `MCP::ServerDiscovery`, which
77
+ scores `"#{name} #{description}"` against the user's message when a robot is
78
+ built with `mcp_discovery: true`. Discovery falls back to the full server list
79
+ when no server in the list has a non-empty description.
80
+
64
81
  ### transport
65
82
 
66
83
  ```ruby
@@ -90,10 +107,14 @@ Returns the transport type string (e.g., `"stdio"`, `"ws"`, `"sse"`).
90
107
  ### to_h
91
108
 
92
109
  ```ruby
93
- server.to_h # => { name: "...", transport: { ... }, timeout: 15 }
110
+ server.to_h
111
+ # => { name: "y", description: "", transport: { type: "sse", url: "http://x" }, timeout: 30.0 }
94
112
  ```
95
113
 
96
- Converts the server configuration to a hash representation (includes `timeout`).
114
+ Converts the server configuration to a hash representation. The hash is **not**
115
+ compacted — all four keys are always present, and `description` is `""` when it
116
+ was never set. Note that `timeout` is normalized to a Float unless it defaulted
117
+ (`DEFAULT_TIMEOUT` is the Integer `15`).
97
118
 
98
119
  ## Transport Configuration Options
99
120
 
@@ -207,6 +228,9 @@ robot = Robot.new(
207
228
  system_prompt: "You help with development tasks.",
208
229
  mcp: servers
209
230
  )
231
+
232
+ # `run` defaults to `mcp: :none, tools: :none` — opt in per run
233
+ robot.run("What changed in the repo?", mcp: :inherit, tools: :inherit)
210
234
  ```
211
235
 
212
236
  ### Creating a Client from a Server