robot_lab 0.2.6 → 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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +124 -64
  3. data/docs/api/core/index.md +41 -15
  4. data/docs/api/core/memory.md +247 -29
  5. data/docs/api/core/network.md +255 -33
  6. data/docs/api/core/result.md +120 -32
  7. data/docs/api/core/robot.md +551 -61
  8. data/docs/api/core/state.md +87 -197
  9. data/docs/api/core/tool.md +165 -20
  10. data/docs/api/errors.md +110 -17
  11. data/docs/api/hooks.md +469 -0
  12. data/docs/api/index.md +80 -7
  13. data/docs/api/mcp/client.md +129 -35
  14. data/docs/api/mcp/index.md +164 -23
  15. data/docs/api/mcp/server.md +27 -3
  16. data/docs/api/mcp/transports.md +94 -22
  17. data/docs/api/messages/index.md +26 -3
  18. data/docs/api/messages/text-message.md +33 -11
  19. data/docs/api/messages/tool-call-message.md +27 -4
  20. data/docs/api/messages/tool-result-message.md +23 -4
  21. data/docs/api/messages/user-message.md +45 -8
  22. data/docs/api/skills.md +519 -0
  23. data/docs/api/streaming/context.md +28 -5
  24. data/docs/api/streaming/index.md +57 -11
  25. data/docs/api/support.md +846 -0
  26. data/docs/architecture/core-concepts.md +79 -31
  27. data/docs/architecture/index.md +86 -11
  28. data/docs/architecture/message-flow.md +66 -29
  29. data/docs/architecture/network-orchestration.md +145 -38
  30. data/docs/architecture/robot-execution.md +172 -90
  31. data/docs/architecture/state-management.md +31 -12
  32. data/docs/concepts.md +176 -21
  33. data/docs/examples/basic-chat.md +72 -19
  34. data/docs/examples/index.md +117 -31
  35. data/docs/examples/mcp-server.md +154 -45
  36. data/docs/examples/multi-robot-network.md +91 -21
  37. data/docs/examples/tool-usage.md +104 -37
  38. data/docs/getting-started/configuration.md +284 -97
  39. data/docs/getting-started/installation.md +53 -41
  40. data/docs/getting-started/quick-start.md +51 -6
  41. data/docs/guides/building-robots.md +258 -50
  42. data/docs/guides/creating-networks.md +214 -30
  43. data/docs/guides/hooks.md +141 -54
  44. data/docs/guides/knowledge.md +35 -4
  45. data/docs/guides/mcp-integration.md +211 -44
  46. data/docs/guides/memory.md +103 -12
  47. data/docs/guides/observability.md +95 -47
  48. data/docs/guides/streaming.md +184 -125
  49. data/docs/guides/using-tools.md +237 -17
  50. data/docs/index.md +36 -4
  51. data/examples/01_simple_robot.rb +2 -2
  52. data/examples/02_tools.rb +14 -4
  53. data/examples/03_network.rb +12 -7
  54. data/examples/04_mcp.rb +11 -4
  55. data/examples/05_streaming.rb +8 -5
  56. data/examples/06_prompt_templates.rb +13 -9
  57. data/examples/07_network_memory.rb +5 -5
  58. data/examples/08_llm_config.rb +20 -15
  59. data/examples/09_chaining.rb +4 -4
  60. data/examples/11_network_introspection.rb +4 -4
  61. data/examples/12_message_bus.rb +2 -2
  62. data/examples/13_spawn.rb +2 -2
  63. data/examples/14_rusty_circuit/README.md +1 -0
  64. data/examples/14_rusty_circuit/comic.rb +7 -3
  65. data/examples/14_rusty_circuit/display.rb +14 -0
  66. data/examples/14_rusty_circuit/heckler.rb +8 -6
  67. data/examples/14_rusty_circuit/open_mic.rb +17 -6
  68. data/examples/14_rusty_circuit/scout.rb +17 -10
  69. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +14 -10
  70. data/examples/15_memory_network_and_bus/linux_writer.rb +2 -2
  71. data/examples/15_memory_network_and_bus/os_editor.rb +3 -1
  72. data/examples/15_memory_network_and_bus/os_writer.rb +4 -1
  73. data/examples/16_writers_room/writer.rb +22 -22
  74. data/examples/16_writers_room/writers_room.rb +2 -0
  75. data/examples/17_skills.rb +14 -13
  76. data/examples/18_rails/README.md +20 -1
  77. data/examples/18_rails/app/controllers/chat_controller.rb +5 -1
  78. data/examples/18_rails/app/jobs/robot_run_job.rb +11 -5
  79. data/examples/18_rails/app/robots/chat_robot.rb +11 -0
  80. data/examples/18_rails/config/initializers/robot_lab.rb +8 -0
  81. data/examples/19_token_tracking.rb +25 -9
  82. data/examples/20_circuit_breaker.rb +10 -7
  83. data/examples/21_learning_loop.rb +42 -16
  84. data/examples/22_context_compression.rb +23 -23
  85. data/examples/23_convergence.rb +24 -17
  86. data/examples/24_structured_delegation.rb +13 -8
  87. data/examples/25_history_search.rb +12 -8
  88. data/examples/27_incident_response/incident_response.rb +31 -13
  89. data/examples/28_mcp_discovery.rb +17 -13
  90. data/examples/29_ractor_tools.rb +4 -2
  91. data/examples/30_ractor_network.rb +22 -17
  92. data/examples/31_launch_assessment.rb +20 -9
  93. data/examples/32_newsletter_reader.rb +7 -2
  94. data/examples/33_stock_predictor.rb +34 -13
  95. data/examples/34_agentskills.rb +7 -3
  96. data/examples/35_hooks.rb +18 -8
  97. data/examples/README.md +199 -45
  98. data/examples/common.rb +79 -11
  99. data/examples/xyzzy.rb +8 -1
  100. data/lib/robot_lab/config.rb +10 -5
  101. data/lib/robot_lab/names.rb +402 -0
  102. data/lib/robot_lab/robot/agent_skill_matching.rb +1 -3
  103. data/lib/robot_lab/robot/bus_messaging.rb +16 -8
  104. data/lib/robot_lab/robot/template_rendering.rb +16 -3
  105. data/lib/robot_lab/robot.rb +23 -2
  106. data/lib/robot_lab/version.rb +1 -1
  107. data/lib/robot_lab.rb +21 -15
  108. data/mkdocs.yml +6 -1
  109. metadata +7 -2
data/docs/api/index.md CHANGED
@@ -8,10 +8,50 @@ The fundamental building blocks of RobotLab:
8
8
 
9
9
  | Class | Description |
10
10
  |-------|-------------|
11
- | [Robot](core/robot.md) | LLM-powered agent with personality and tools |
12
- | [Network](core/network.md) | Orchestrates multiple robots |
11
+ | [Robot](core/robot.md) | LLM-powered agent with templates, tools, memory, and MCP |
12
+ | [RobotResult](core/result.md) | Value object returned by every `robot.run()` |
13
+ | [Network](core/network.md) | Orchestrates multiple robots as a SimpleFlow pipeline |
13
14
  | [Memory](core/memory.md) | Reactive key-value store for sharing data |
15
+ | [StateProxy](core/state.md) | Hash/method-access wrapper returned by `memory.data` |
14
16
  | [Tool](core/tool.md) | Custom function robots can call |
17
+ | `RunConfig` | Shared LLM / tool / infrastructure configuration — see [Robot: RunConfig](core/robot.md#runconfig) |
18
+
19
+ There is **no** `RobotLab::State` class and no `RobotLab::NetworkRun` class. Runtime
20
+ state lives in `Memory`; `memory.data` returns a `StateProxy`.
21
+
22
+ ## Hooks
23
+
24
+ The framework's extension seam — `Hooks.run` brackets every robot run, network
25
+ run, task, LLM generation, tool call, compaction, and `learn` call:
26
+
27
+ | Class | Description |
28
+ |-------|-------------|
29
+ | [Hook](hooks.md#robotlabhook) | Handler base class; subclasses implement hooks as class methods |
30
+ | [HookRegistry](hooks.md#robotlabhookregistry) | The store behind `RobotLab.hooks`, `network.hooks`, `robot.hooks` |
31
+ | [Hooks](hooks.md#robotlabhooks) | The dispatcher |
32
+ | [HookContext](hooks.md#robotlabhookcontext) | Base context, plus one subclass per family |
33
+
34
+ ## Skills
35
+
36
+ `SKILL.md` bundles, the scripts they expose as tools, and the sandbox that
37
+ confines them:
38
+
39
+ | Class | Description |
40
+ |-------|-------------|
41
+ | [AgentSkill](skills.md#robotlabagentskill) | One skill bundle: instructions + `scripts/` |
42
+ | [AgentSkillCatalog](skills.md#robotlabagentskillcatalog) | Lazy registry over `~/.prompts/skills/` |
43
+ | [Capabilities](skills.md#robotlabcapabilities) | Declared vs. ceiling filesystem / network / timeout grant |
44
+ | [ScriptTool](skills.md#robotlabscripttool) | Wraps an executable script as a `Tool` |
45
+ | [Sandbox](skills.md#robotlabsandbox) | macOS Seatbelt confinement, or a passthrough |
46
+
47
+ ## Support Classes
48
+
49
+ Everything else with a public surface — `Task`, `Runnable`, `ToolConfig`,
50
+ `ToolManifest`, `Budget::Ledger`, `DoomLoopDetector`, `HistoryCompressor`,
51
+ `Convergence`, `TextAnalysis`, `DelegationFuture`, `RobotMessage`, `BusPoller`,
52
+ `Waiter`, `Narrator`, `Config`, `MCP::ServerDiscovery`,
53
+ `MCP::ConnectionPoller`, and `Streaming::SequenceCounter` — is documented on the
54
+ [Support Classes](support.md) page.
15
55
 
16
56
  ## Messages
17
57
 
@@ -49,16 +89,49 @@ Real-time response streaming:
49
89
 
50
90
  ```ruby
51
91
  # Configuration
52
- RobotLab.config # => Config instance
53
- RobotLab.reload_config! # => reload from all sources
92
+ RobotLab.config # => Config instance
93
+ RobotLab.configure { |c| ... } # => yields the Config for mutation
94
+ RobotLab.reload_config! # => reload from all sources
54
95
 
55
96
  # Building
56
- RobotLab.build(name:, template:, system_prompt:, context:, **options)
57
- RobotLab.create_network(name:, concurrency:) { ... }
58
- RobotLab.create_memory(data:, enable_cache:, **options)
97
+ RobotLab.build(name: "robot", template: nil, system_prompt: nil, context: {},
98
+ enable_cache: true, bus: nil, skills: nil, config: nil, **options)
99
+ RobotLab.create_network(name:, concurrency: :auto, config: nil) { ... }
100
+ RobotLab.create_memory(data: {}, enable_cache: true, **options)
59
101
 
60
102
  # Rendering a template to a String (not a robot) -- see Building Robots guide
61
103
  RobotLab.render_template(name, **context) # => String
104
+
105
+ # Hooks -- see the Hooks API page
106
+ RobotLab.hooks # => HookRegistry
107
+ RobotLab.on(HandlerClass, context: nil)
108
+ RobotLab.clear_hooks! # => replaces the global registry with a fresh one
109
+
110
+ # Hook scope (internal plumbing; read by Tool#call)
111
+ RobotLab.with_hook_scope(registries, per_run_hooks) { ... }
112
+ RobotLab.current_hook_scope # => { registries:, per_run_hooks: } or nil
113
+
114
+ # Extensions
115
+ RobotLab.register_extension(name, mod)
116
+ RobotLab.extension_loaded?(:ractor) # => Boolean
117
+ RobotLab.extension(:ractor)
118
+ RobotLab.loaded_extensions # => Array<Symbol>
62
119
  ```
63
120
 
121
+ `with_hook_scope` publishes the active run's registries in a `Thread.current`
122
+ slot so a tool executing deep inside RubyLLM's tool loop resolves the same
123
+ registries — including the network's and any per-run `hooks:` — instead of
124
+ falling back to `[RobotLab.hooks, robot.hooks]`. It is thread-local, so it does
125
+ not reach a tool that runs on another thread. See
126
+ [Hooks: with_hook_scope](hooks.md#with_hook_scope-current_hook_scope).
127
+
128
+ !!! warning "Tools and MCP are opt-in per call"
129
+ `Robot#run` defaults to `tools: :none, mcp: :none`. A bare `robot.run("...")`
130
+ sends the LLM **zero** tools and connects **no** MCP servers, even when
131
+ `local_tools:`/`mcp:` were supplied at build time. Pass `tools: :inherit`
132
+ (and `mcp: :inherit`) at *run* time to use what is attached. For a standalone
133
+ robot, do not pass `tools: :inherit` at *build* time — it resolves to an
134
+ allowlist matching nothing. (Inside a network it is the required opt-in to a
135
+ network `config:` list.) See [Robot: Configuration Hierarchy](core/robot.md#configuration-hierarchy).
136
+
64
137
  See individual class documentation for detailed method references.
@@ -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)