robot_lab 0.3.0 → 0.5.0

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +29 -19
  4. data/lib/robot_lab/errors.rb +4 -0
  5. data/lib/robot_lab/hook_context.rb +9 -2
  6. data/lib/robot_lab/mcp/registry.rb +99 -0
  7. data/lib/robot_lab/mcp/server.rb +47 -83
  8. data/lib/robot_lab/mcp/server_discovery.rb +22 -9
  9. data/lib/robot_lab/mcp.rb +259 -0
  10. data/lib/robot_lab/robot/mcp_management.rb +159 -105
  11. data/lib/robot_lab/robot/tool_call_gating.rb +144 -0
  12. data/lib/robot_lab/robot.rb +72 -99
  13. data/lib/robot_lab/run_config.rb +3 -2
  14. data/lib/robot_lab/tool.rb +8 -23
  15. data/lib/robot_lab/version.rb +1 -1
  16. data/lib/robot_lab.rb +1 -4
  17. metadata +16 -292
  18. data/.architecture/AGENTS.md +0 -32
  19. data/.architecture/config.yml +0 -8
  20. data/.architecture/members.yml +0 -60
  21. data/.architecture/reviews/feature-free-will.md +0 -490
  22. data/.architecture/reviews/overall-codebase.md +0 -427
  23. data/.claude/settings.local.json +0 -57
  24. data/.codex/config.toml +0 -2
  25. data/.envrc +0 -6
  26. data/.github/workflows/deploy-github-pages.yml +0 -52
  27. data/.irbrc +0 -6
  28. data/.loki +0 -65
  29. data/.quality/reek_baseline.txt +0 -43
  30. data/.rubocop.yml +0 -10
  31. data/Archspec.rb +0 -44
  32. data/CLAUDE.md +0 -141
  33. data/COMMITS.md +0 -196
  34. data/Rakefile +0 -193
  35. data/_typos.toml +0 -21
  36. data/agent2agent_review.md +0 -192
  37. data/agentf_improvements.md +0 -253
  38. data/agents.md +0 -14
  39. data/docs/api/core/index.md +0 -147
  40. data/docs/api/core/memory.md +0 -578
  41. data/docs/api/core/network.md +0 -553
  42. data/docs/api/core/result.md +0 -211
  43. data/docs/api/core/robot.md +0 -1667
  44. data/docs/api/core/state.md +0 -145
  45. data/docs/api/core/tool.md +0 -520
  46. data/docs/api/errors.md +0 -323
  47. data/docs/api/hooks.md +0 -469
  48. data/docs/api/index.md +0 -137
  49. data/docs/api/mcp/client.md +0 -387
  50. data/docs/api/mcp/index.md +0 -267
  51. data/docs/api/mcp/server.md +0 -252
  52. data/docs/api/mcp/transports.md +0 -339
  53. data/docs/api/messages/index.md +0 -126
  54. data/docs/api/messages/text-message.md +0 -170
  55. data/docs/api/messages/tool-call-message.md +0 -206
  56. data/docs/api/messages/tool-result-message.md +0 -242
  57. data/docs/api/messages/user-message.md +0 -232
  58. data/docs/api/skills.md +0 -423
  59. data/docs/api/streaming/context.md +0 -280
  60. data/docs/api/streaming/events.md +0 -185
  61. data/docs/api/streaming/index.md +0 -156
  62. data/docs/api/support.md +0 -846
  63. data/docs/architecture/core-concepts.md +0 -575
  64. data/docs/architecture/index.md +0 -252
  65. data/docs/architecture/message-flow.md +0 -366
  66. data/docs/architecture/network-orchestration.md +0 -478
  67. data/docs/architecture/robot-execution.md +0 -396
  68. data/docs/architecture/state-management.md +0 -410
  69. data/docs/assets/css/custom.css +0 -56
  70. data/docs/assets/images/robot_lab.jpg +0 -0
  71. data/docs/concepts.md +0 -641
  72. data/docs/examples/basic-chat.md +0 -258
  73. data/docs/examples/index.md +0 -425
  74. data/docs/examples/mcp-server.md +0 -403
  75. data/docs/examples/multi-robot-network.md +0 -476
  76. data/docs/examples/tool-usage.md +0 -468
  77. data/docs/getting-started/configuration.md +0 -645
  78. data/docs/getting-started/index.md +0 -56
  79. data/docs/getting-started/installation.md +0 -197
  80. data/docs/getting-started/quick-start.md +0 -293
  81. data/docs/guides/building-robots.md +0 -1187
  82. data/docs/guides/creating-networks.md +0 -736
  83. data/docs/guides/hooks.md +0 -1206
  84. data/docs/guides/index.md +0 -78
  85. data/docs/guides/knowledge.md +0 -219
  86. data/docs/guides/mcp-integration.md +0 -740
  87. data/docs/guides/memory.md +0 -458
  88. data/docs/guides/observability.md +0 -733
  89. data/docs/guides/streaming.md +0 -323
  90. data/docs/guides/using-tools.md +0 -780
  91. data/docs/index.md +0 -280
  92. data/docs/superpowers/plans/2026-05-06-agentskills.md +0 -1303
  93. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +0 -247
  94. data/examples/.envrc +0 -3
  95. data/examples/01_simple_robot.rb +0 -31
  96. data/examples/02_tools.rb +0 -109
  97. data/examples/03_network.rb +0 -118
  98. data/examples/04_mcp.rb +0 -217
  99. data/examples/05_streaming.rb +0 -137
  100. data/examples/06_prompt_templates.rb +0 -325
  101. data/examples/07_network_memory.rb +0 -313
  102. data/examples/08_llm_config.rb +0 -162
  103. data/examples/09_chaining.rb +0 -251
  104. data/examples/10_memory.rb +0 -315
  105. data/examples/11_network_introspection.rb +0 -239
  106. data/examples/12_message_bus.rb +0 -67
  107. data/examples/13_spawn.rb +0 -85
  108. data/examples/14_rusty_circuit/.envrc +0 -1
  109. data/examples/14_rusty_circuit/.gitignore +0 -1
  110. data/examples/14_rusty_circuit/README.md +0 -1
  111. data/examples/14_rusty_circuit/comic.rb +0 -149
  112. data/examples/14_rusty_circuit/display.rb +0 -217
  113. data/examples/14_rusty_circuit/heckler.rb +0 -65
  114. data/examples/14_rusty_circuit/open_mic.rb +0 -132
  115. data/examples/14_rusty_circuit/prompts/open_mic_comic.md +0 -20
  116. data/examples/14_rusty_circuit/prompts/open_mic_heckler.md +0 -23
  117. data/examples/14_rusty_circuit/prompts/open_mic_scout.md +0 -20
  118. data/examples/14_rusty_circuit/scout.rb +0 -165
  119. data/examples/14_rusty_circuit/show.log +0 -234
  120. data/examples/15_memory_network_and_bus/.envrc +0 -1
  121. data/examples/15_memory_network_and_bus/README.md +0 -66
  122. data/examples/15_memory_network_and_bus/editor_in_chief.rb +0 -24
  123. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +0 -213
  124. data/examples/15_memory_network_and_bus/linux_writer.rb +0 -80
  125. data/examples/15_memory_network_and_bus/os_editor.rb +0 -48
  126. data/examples/15_memory_network_and_bus/os_writer.rb +0 -49
  127. data/examples/15_memory_network_and_bus/output/combined_article.md +0 -11
  128. data/examples/15_memory_network_and_bus/output/final_article.md +0 -8
  129. data/examples/15_memory_network_and_bus/output/linux_draft.md +0 -7
  130. data/examples/15_memory_network_and_bus/output/mac_draft.md +0 -7
  131. data/examples/15_memory_network_and_bus/output/memory.json +0 -13
  132. data/examples/15_memory_network_and_bus/output/revision_1.md +0 -29
  133. data/examples/15_memory_network_and_bus/output/revision_2.md +0 -50
  134. data/examples/15_memory_network_and_bus/output/revision_3.md +0 -8
  135. data/examples/15_memory_network_and_bus/output/windows_draft.md +0 -7
  136. data/examples/15_memory_network_and_bus/prompts/os_advocate.md +0 -13
  137. data/examples/15_memory_network_and_bus/prompts/os_chief.md +0 -13
  138. data/examples/15_memory_network_and_bus/prompts/os_editor.md +0 -13
  139. data/examples/16_writers_room/.envrc +0 -1
  140. data/examples/16_writers_room/display.rb +0 -158
  141. data/examples/16_writers_room/output/.gitignore +0 -4
  142. data/examples/16_writers_room/output/README.md +0 -69
  143. data/examples/16_writers_room/output/opus_001.md +0 -263
  144. data/examples/16_writers_room/output/opus_001_notes.log +0 -470
  145. data/examples/16_writers_room/output/opus_002.md +0 -245
  146. data/examples/16_writers_room/output/opus_002_notes.log +0 -546
  147. data/examples/16_writers_room/output/opus_002_screenplay.md +0 -7989
  148. data/examples/16_writers_room/output/opus_002_screenplay_notes.md +0 -993
  149. data/examples/16_writers_room/prompts/screenplay_writer.md +0 -66
  150. data/examples/16_writers_room/prompts/writer.md +0 -37
  151. data/examples/16_writers_room/room.rb +0 -186
  152. data/examples/16_writers_room/tools.rb +0 -173
  153. data/examples/16_writers_room/writer.rb +0 -121
  154. data/examples/16_writers_room/writers_room.rb +0 -256
  155. data/examples/17_skills.rb +0 -234
  156. data/examples/18_rails/.envrc +0 -3
  157. data/examples/18_rails/.gitignore +0 -5
  158. data/examples/18_rails/Gemfile +0 -11
  159. data/examples/18_rails/README.md +0 -67
  160. data/examples/18_rails/Rakefile +0 -4
  161. data/examples/18_rails/app/controllers/application_controller.rb +0 -4
  162. data/examples/18_rails/app/controllers/chat_controller.rb +0 -50
  163. data/examples/18_rails/app/jobs/application_job.rb +0 -4
  164. data/examples/18_rails/app/jobs/robot_run_job.rb +0 -25
  165. data/examples/18_rails/app/models/application_record.rb +0 -5
  166. data/examples/18_rails/app/models/robot_lab_result.rb +0 -36
  167. data/examples/18_rails/app/models/robot_lab_thread.rb +0 -23
  168. data/examples/18_rails/app/robots/chat_robot.rb +0 -25
  169. data/examples/18_rails/app/tools/time_tool.rb +0 -9
  170. data/examples/18_rails/app/views/chat/_user_message.html.erb +0 -1
  171. data/examples/18_rails/app/views/chat/index.html.erb +0 -67
  172. data/examples/18_rails/app/views/layouts/application.html.erb +0 -49
  173. data/examples/18_rails/bin/dev +0 -7
  174. data/examples/18_rails/bin/rails +0 -6
  175. data/examples/18_rails/bin/setup +0 -15
  176. data/examples/18_rails/config/application.rb +0 -33
  177. data/examples/18_rails/config/cable.yml +0 -2
  178. data/examples/18_rails/config/database.yml +0 -5
  179. data/examples/18_rails/config/environment.rb +0 -4
  180. data/examples/18_rails/config/initializers/robot_lab.rb +0 -11
  181. data/examples/18_rails/config/routes.rb +0 -6
  182. data/examples/18_rails/config.ru +0 -4
  183. data/examples/18_rails/db/migrate/001_create_robot_lab_tables.rb +0 -32
  184. data/examples/19_token_tracking.rb +0 -138
  185. data/examples/20_circuit_breaker.rb +0 -147
  186. data/examples/21_learning_loop.rb +0 -181
  187. data/examples/22_context_compression.rb +0 -172
  188. data/examples/23_convergence.rb +0 -133
  189. data/examples/24_structured_delegation.rb +0 -151
  190. data/examples/25_history_search/conversation.jsonl +0 -30
  191. data/examples/25_history_search.rb +0 -133
  192. data/examples/26_document_store/api_versioning_adr.md +0 -52
  193. data/examples/26_document_store/incident_postmortem.md +0 -46
  194. data/examples/26_document_store/postgres_runbook.md +0 -49
  195. data/examples/26_document_store/redis_caching_guide.md +0 -48
  196. data/examples/26_document_store/sidekiq_guide.md +0 -51
  197. data/examples/26_document_store.rb +0 -140
  198. data/examples/27_incident_response/README.md +0 -65
  199. data/examples/27_incident_response/incident_response.rb +0 -261
  200. data/examples/28_mcp_discovery.rb +0 -113
  201. data/examples/29_ractor_tools.rb +0 -240
  202. data/examples/30_ractor_network.rb +0 -252
  203. data/examples/31_launch_assessment.rb +0 -246
  204. data/examples/32_newsletter_reader.rb +0 -193
  205. data/examples/33_stock_generator.rb +0 -80
  206. data/examples/33_stock_predictor.rb +0 -327
  207. data/examples/34_agentskills.rb +0 -76
  208. data/examples/35_hooks.rb +0 -266
  209. data/examples/README.md +0 -550
  210. data/examples/common.rb +0 -180
  211. data/examples/prompts/assistant.md +0 -5
  212. data/examples/prompts/audit_trail.md +0 -8
  213. data/examples/prompts/billing.md +0 -10
  214. data/examples/prompts/classifier.md +0 -7
  215. data/examples/prompts/comedian.md +0 -6
  216. data/examples/prompts/comedy_critic.md +0 -10
  217. data/examples/prompts/configurable.md +0 -9
  218. data/examples/prompts/dispatcher.md +0 -12
  219. data/examples/prompts/entity_extractor.md +0 -14
  220. data/examples/prompts/escalation.md +0 -42
  221. data/examples/prompts/frontmatter_mcp_test.md +0 -9
  222. data/examples/prompts/frontmatter_named_test.md +0 -5
  223. data/examples/prompts/frontmatter_tools_test.md +0 -6
  224. data/examples/prompts/general.md +0 -7
  225. data/examples/prompts/github_assistant.md +0 -14
  226. data/examples/prompts/helper.md +0 -4
  227. data/examples/prompts/incident_responder.md +0 -18
  228. data/examples/prompts/keyword_extractor.md +0 -11
  229. data/examples/prompts/llm_config_demo.md +0 -20
  230. data/examples/prompts/order_support.md +0 -35
  231. data/examples/prompts/os_advocate.md +0 -13
  232. data/examples/prompts/os_chief.md +0 -13
  233. data/examples/prompts/os_editor.md +0 -13
  234. data/examples/prompts/parameterized_main_test.md +0 -6
  235. data/examples/prompts/pii_redactor.md +0 -7
  236. data/examples/prompts/product_support.md +0 -37
  237. data/examples/prompts/runbook_protocol.md +0 -12
  238. data/examples/prompts/sentiment_analyzer.md +0 -12
  239. data/examples/prompts/skill_a_test.md +0 -4
  240. data/examples/prompts/skill_b_test.md +0 -4
  241. data/examples/prompts/skill_config_test.md +0 -5
  242. data/examples/prompts/skill_cycle_a_test.md +0 -6
  243. data/examples/prompts/skill_cycle_b_test.md +0 -6
  244. data/examples/prompts/skill_description_test.md +0 -4
  245. data/examples/prompts/skill_leaf_test.md +0 -4
  246. data/examples/prompts/skill_nested_test.md +0 -6
  247. data/examples/prompts/skill_refs_main_test.md +0 -6
  248. data/examples/prompts/skill_self_ref_test.md +0 -6
  249. data/examples/prompts/skill_with_mcp_test.md +0 -9
  250. data/examples/prompts/skill_with_params_test.md +0 -6
  251. data/examples/prompts/skill_with_robot_name_test.md +0 -5
  252. data/examples/prompts/skill_with_tools_test.md +0 -6
  253. data/examples/prompts/sre_compliance.md +0 -10
  254. data/examples/prompts/structured_output.md +0 -7
  255. data/examples/prompts/synthesizer.md +0 -17
  256. data/examples/prompts/technical.md +0 -10
  257. data/examples/prompts/template_with_skills_test.md +0 -6
  258. data/examples/prompts/triage.md +0 -22
  259. data/examples/ruboruby.md +0 -423
  260. data/examples/run_all.rb +0 -60
  261. data/examples/xyzzy.rb +0 -97
  262. data/lib/robot_lab/mcp/client.rb +0 -223
  263. data/lib/robot_lab/mcp/connection_poller.rb +0 -195
  264. data/lib/robot_lab/mcp/transports/base.rb +0 -64
  265. data/lib/robot_lab/mcp/transports/sse.rb +0 -120
  266. data/lib/robot_lab/mcp/transports/stdio.rb +0 -170
  267. data/lib/robot_lab/mcp/transports/streamable_http.rb +0 -141
  268. data/lib/robot_lab/mcp/transports/websocket.rb +0 -111
  269. data/logfile +0 -8
  270. data/mkdocs.yml +0 -209
  271. data/robot_concurrency.md +0 -38
  272. data/simple_acp_review.md +0 -298
  273. data/temp.md +0 -6
  274. data/tool_manifest_plan.md +0 -155
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 411c496dec788609f2ac0d2e1eb02ce7fd0523f40424b9437de0ffb24c00c644
4
- data.tar.gz: 9fb9bb55b865734dc7c9ae1ac8d1994685575c1ee206d6cf9865c0af3d3a522c
3
+ metadata.gz: 65a808d1e3b2ba828752c2382fd3fc3f1193a66f5fb5d37226e0429747f5748e
4
+ data.tar.gz: b86e5d9c0e79d53ececcb0f0fe84e1e632579ebab8e49649989f825a8e7f0c72
5
5
  SHA512:
6
- metadata.gz: b34ce72ebe6443c5f25423a595c0ffd468416ea1be02531b125efed33289ae8ea898c5413697fe373cebd658bddb510f322ef09623bb7b74f7587cf58b9ad064
7
- data.tar.gz: e9884f0c8b501bfc57eafdc6c5492fea7455eb807267c370fc68b59e7322f97c0360e3af6f7e61b7c2493df3e2026f72095b9c287e65e6bf5f1ec5274948ef7d
6
+ metadata.gz: 0f6cdec05c631f732d1806f70d6d3a3cb2eb37ee71e611a528508180e9c43b0900c8807e71c893854042f92e48b78f51c2652c2cdc8269b6f5055eed4c981986
7
+ data.tar.gz: f4e03bfce73ec1548797a7a4ca2ea7fa12516ec184dab78e78d030b21c728ccc5d051a54a768e6e250c18b4bfb13f35c204b09966b4714116a0b40f0f09952de
data/CHANGELOG.md CHANGED
@@ -6,6 +6,66 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [Unreleased]
10
+
11
+ robot_lab 0.4.0: MCP (Model Context Protocol) now runs on ruby_llm 2.1's
12
+ built-in client. robot_lab's own JSON-RPC client, four transports and
13
+ connection poller are gone; a robot's MCP servers are `RubyLLM::MCP`
14
+ objects with the whole protocol surface (tools, resources, prompts, OAuth,
15
+ input requests, long-running tasks, deferred tools), not just tools. This
16
+ is a deliberate breaking release with no legacy API; see "Migrating from
17
+ 0.3" in `docs/guides/mcp-integration.md`.
18
+
19
+ ### Changed (BREAKING)
20
+
21
+ - **ruby_llm 2.1.1 or newer is required** (`ruby_llm ">= 2.1.1", "< 3"`). The `async-http` and `async-websocket` dependencies are dropped.
22
+ - **MCP servers are `RubyLLM::MCP` objects.** `mcp:` (constructor, `RunConfig`, template front matter, `Network#task`) takes an Array mixing config Hashes, `RubyLLM::MCP` instances and `RubyLLM::MCP` subclasses. Hashes become anonymous `RobotLab::MCP::Server` subclasses via `RobotLab::MCP.define`. The `mcp_servers:` constructor keyword is removed; use `mcp:`.
23
+ - **`Robot#mcp` is the robot's `RobotLab::MCP::Registry`** (keyed by server name) and `Robot#with_mcp(*servers)` registers servers on the robot; neither delegates to the chat any more. `robot.mcp[:github]` is the `RubyLLM::MCP` instance: `.tools .call .resources .resource(uri) .prompts .prompt(name, **args) .instructions .close` and the OAuth flow.
24
+ - **MCP tools are `RubyLLM::MCP::Tool` instances**, attached to the chat per run with `with_tools` beside local tools. The per-run `tools:` allowlist, `max_tools` cap and `:none` apply to them exactly as to local tools, and `mcp: :none` on a later run detaches the MCP tools that an earlier run attached while the servers stay registered and connected. Two tools with one name now raise `MCPError` (the message suggests `prefix:`) instead of silently shadowing each other.
25
+ - **Tool calls that require approval fail closed.** A tool a server (or `RubyLLM::Tool.requires_approval`) marks as needing approval is denied with a warning unless the robot has `tool_approval: :approve` or an `on_tool_approval:` Proc.
26
+ - **Only stdio and Streamable HTTP transports exist.** `type: "sse"`, `"ws"` and `"websocket"` raise `MCPError` at build time with guidance; Streamable HTTP must be `https` except on loopback. A `transport:` object that responds to `request`/`notify`/`cancel`/`close` is installed directly.
27
+ - **Timeouts.** `timeout:` is seconds and defaults to ruby_llm's `request_timeout` (was a 15 s default). Values of 1000 or more are still read as milliseconds.
28
+ - **`connect_mcp!` registers and connects the build-time servers**; failures land in `Robot#mcp_failures` / `#failed_mcp_server_names` instead of raising, and a failed server is retried on the next run that selects it.
29
+ - `Robot#to_h` no longer has `mcp_tools` (it would force connections); `mcp_servers` lists registered names and `mcp_failed` appears when any server failed.
30
+ - `RobotLab::Tool.create` no longer takes `mcp:`; `Tool#mcp`, `Tool#mcp?` and the `:mcp` key of `Tool#to_h` are removed.
31
+ - `MCP::ServerDiscovery` runs on every message (not only the first run) and accepts Hashes with symbol or string keys, `RubyLLM::MCP` instances and subclasses.
32
+
33
+ ### Added
34
+
35
+ - `RobotLab::MCP.build(spec)`, `.define(config)`, `.name_of(spec)`, `.seconds(timeout)` and the `HTTP_TYPES`, `UNSUPPORTED_TYPES`, `IGNORED_KEYS`, `TRANSPORT_KEYS`, `SETTING_KEYS`, `CONNECT_ERRORS` constants. Config keys map onto ruby_llm's DSL: `command`/`args`/`env`/`directory` (or `cwd`), `url`/`headers`/`bearer_token`/`oauth`, `timeout`, `prefix`, `only`, `except`, `requires_approval`, `defer`, `log_level`, `input_requests`, `extensions`, `description`. The flat shape (`transport: stdio` with sibling `command:`/`args:`), which 0.3 front matter used and which failed silently, now works.
36
+ - `RobotLab::MCP::Server < RubyLLM::MCP` with a `description` class macro (used by `ServerDiscovery`) and `#connected?`.
37
+ - `RobotLab::MCP::Registry` — one connection (and one stdio process) per server per robot across runs; re-adding the same Hash, instance or class returns the existing server.
38
+ - `tool_approval`, `on_tool_approval` and `on_input_request` as constructor keywords and `RunConfig` fields. Runs settle approvals, answer or decline MCP input requests (elicitation) and wait on MCP tasks (up to `MCP_TASK_TIMEOUT`, then `MCPError(retryable: true)`) before returning.
39
+ - `Robot#run` accepts a `RubyLLM::MCP::Prompt` (`robot.run(robot.mcp[:github].prompt("code_review", code: diff))`).
40
+ - `:tool_call` hooks now run for MCP tools and plain `RubyLLM::Tool` instances (the chat's `execute_tool` is bridged), with `ToolCallHookContext#mcp_server` and `#mcp?`. A `RubyLLM::MCP::Error` raised mid-call becomes an `{ error: }` tool result instead of aborting the run.
41
+ - `Robot#mcp_failures`, `#mcp_tools(servers)`, `#mcp_server_for(tool)`; servers' deferred tools (`defer:`) are attached with `defer: true`.
42
+ - `RobotLab::Errors.retryable?` treats a `RubyLLM::MCP::Error` without a JSON-RPC code (transport failure) as retryable and one with a code as not.
43
+ - `test/support/fake_mcp_transport.rb`: an in-process MCP transport (not shipped in the gem) that drives the whole stack without a server process.
44
+
45
+ ### Removed
46
+
47
+ - `RobotLab::MCP::Client`, `RobotLab::MCP::ConnectionPoller`, `RobotLab::MCP::Transports::{Base,Stdio,SSE,WebSocket,StreamableHTTP}` and their tests.
48
+ - `Robot#mcp_clients`, `Robot#mcp_client(name)`, `Robot#inject_mcp!`, the `mcp_servers:` keyword, `MCP::Server::VALID_TRANSPORT_TYPES`, `MCP::Server::DEFAULT_TIMEOUT`.
49
+
50
+ ### Fixed
51
+
52
+ - JSON-RPC error responses from a server are no longer silently ignored.
53
+ - Servers added after a robot's first run are now connected (the old client only initialized once).
54
+ - Remote servers no longer report `connected?` before the handshake completes.
55
+ - Template front matter in the flat `transport: stdio` form no longer fails silently.
56
+
57
+ ## [0.3.1] - 2026-11-01
58
+
59
+ ### Changed
60
+
61
+ - **Minimum Ruby raised to 4.0.9.** `required_ruby_version` is now `>= 4.0.9` (was `>= 3.2.0`), and a `.ruby-version` file pins development to 4.0.9.
62
+ - **Slimmer gem.** The gemspec now whitelists what ships in the `.gem`: `lib/`, `sig/`, `exe/`, README, CHANGELOG, and LICENSE. Previous releases packaged everything tracked by git — the docs site, examples with generated output, tooling config, and local design notes — which made 0.3.0 a 1.1 MB gem of 300+ files. 0.3.1 is ~130 KB and 65 files; the docs and examples remain on GitHub.
63
+ - Verified against ruby_llm 2.1.1 (the newest release the `~> 2.0` constraint admits) in addition to 2.0.0; the full suite passes on both.
64
+
65
+ ### Fixed
66
+
67
+ - `MCP::Transports::StreamableHTTP#connect` no longer raises inside its Async task when the initialize handshake returns no result; the session id is simply left unset.
68
+
9
69
  ## [0.3.0] - 2026-09-18
10
70
 
11
71
  The ruby_llm 2.0 release. Core moves from ruby_llm 1.x to the official 2.0.0
data/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  - <strong>Extensible Tools</strong> - Custom capabilities with graceful error handling<br>
21
21
  - <strong>Human-in-the-Loop</strong> - AskUser tool for interactive prompting<br>
22
22
  - <strong>Content Streaming</strong> - Stored callbacks, per-call blocks, or both<br>
23
- - <strong>MCP Integration</strong> - Connect to external tool servers with timeouts and retry<br>
23
+ - <strong>MCP Integration</strong> - Tools, resources and prompts from MCP servers through ruby_llm's built-in client<br>
24
24
  - <strong>Local LLM Providers</strong> - LM Studio (via ruby_llm-providers-lms), Ollama, and GPUStack via provider passthrough<br>
25
25
  - <strong>Shared Memory</strong> - Reactive key-value store with subscriptions<br>
26
26
  - <strong>Message Bus</strong> - Bidirectional robot communication via TypedBus<br>
@@ -55,7 +55,7 @@ gem install robot_lab
55
55
 
56
56
  ### Requirements
57
57
 
58
- - Ruby >= 3.2
58
+ - Ruby >= 4.0.9
59
59
  - [One or more API Keys for LLM providers supported by RubyLLM](https://rubyllm.com/configuration/#provider-configuration)
60
60
 
61
61
  For comprehensive guides and API documentation, visit **[https://madbomber.github.io/robot_lab](https://madbomber.github.io/robot_lab)**
@@ -347,7 +347,7 @@ result = tool.call({})
347
347
  # => "Error (fetch_data): connection refused"
348
348
  ```
349
349
 
350
- This applies to all tools — subclasses, factory tools, and MCP tools. For critical tools where you want exceptions to propagate, opt out per class:
350
+ This applies to every `RobotLab::Tool` — subclasses and factory tools. (MCP tools are `RubyLLM::MCP::Tool` instances; a `RubyLLM::MCP::Error` raised during one of their calls becomes an `{ error: }` tool result through the robot's tool-call bridge instead.) For critical tools where you want exceptions to propagate, opt out per class:
351
351
 
352
352
  ```ruby
353
353
  class CriticalTool < RobotLab::Tool
@@ -394,13 +394,13 @@ result = robot.run("Should I start learning Rust?", tools: :inherit)
394
394
  ```
395
395
 
396
396
  > [!WARNING]
397
- > `run()` defaults to `tools: :none` and `mcp: :none`, and `:none` means **send zero tools this turn**. Attaching tools with `local_tools:` (or servers with `mcp:`) is therefore not enough on its own — a plain `robot.run("...")` sends the LLM no tools at all and connects no MCP servers.
397
+ > `run()` defaults to `tools: :none` and `mcp: :none`, and `:none` means **send zero tools this turn**. Attaching tools with `local_tools:` (or servers with `mcp:`) is therefore not enough on its own — a plain `robot.run("...")` sends the LLM no tools at all and contacts no MCP servers.
398
398
  >
399
399
  > Pass `tools: :inherit` / `mcp: :inherit` on each run to use what the robot has attached:
400
400
  >
401
401
  > ```ruby
402
402
  > robot.run("...", tools: :inherit) # send attached tools
403
- > robot.run("...", mcp: :inherit, tools: :inherit) # connect MCP + send its tools
403
+ > robot.run("...", mcp: :inherit, tools: :inherit) # select MCP servers + send their tools
404
404
  > ```
405
405
  >
406
406
  > `:inherit` means "no filter, use everything attached"; an explicit array acts as a name allowlist. Note that `tools: :inherit` at *build* time behaves differently — it resolves against the parent level, so keep `:inherit` on the `run()` call.
@@ -510,39 +510,49 @@ Results are keyed in `result.context` by the **robot's** `name`, not by the task
510
510
 
511
511
  ## MCP Integration
512
512
 
513
- Connect to external tool servers via Model Context Protocol:
513
+ RobotLab uses ruby_llm's built-in MCP client (`RubyLLM::MCP`, stdio and Streamable HTTP). A server is a config Hash, a `RubyLLM::MCP` subclass, or an instance:
514
514
 
515
515
  ```ruby
516
- # Configure MCP server (with optional timeout)
517
- filesystem_server = {
516
+ # A config Hash — the same shape works in template front matter and Network#task(mcp:)
517
+ filesystem = {
518
518
  name: "filesystem",
519
519
  transport: {
520
520
  type: "stdio",
521
- command: "mcp-server-filesystem",
522
- args: ["/path/to/allowed/directory"]
521
+ command: "npx",
522
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"]
523
523
  },
524
- timeout: 30 # seconds (default: 15)
524
+ timeout: 30 # seconds; absent means ruby_llm's request_timeout
525
525
  }
526
526
 
527
- # Create robot with MCP server - tools are auto-discovered
527
+ # Or anything RubyLLM::MCP's class DSL offers
528
+ class GitHub < RubyLLM::MCP
529
+ command "github-mcp-server", "stdio"
530
+ env GITHUB_PERSONAL_ACCESS_TOKEN: ENV["GITHUB_PERSONAL_ACCESS_TOKEN"]
531
+ requires_approval if: :destructive?
532
+ end
533
+
528
534
  robot = RobotLab.build(
529
535
  name: "developer",
530
536
  template: :developer,
531
- mcp: [filesystem_server]
537
+ mcp: [filesystem, GitHub],
538
+ tool_approval: :approve # approval-gated tools are denied unless allowed here or via on_tool_approval:
532
539
  )
533
540
 
534
- # Connect. Do this explicitly — run() defaults to mcp: :none, so a plain
535
- # run() never connects the servers configured above.
541
+ # Servers connect on the first run that selects them; connect_mcp! connects now
536
542
  robot.connect_mcp!
537
-
538
- # Check connection status
539
543
  puts "Failed: #{robot.failed_mcp_server_names}" if robot.failed_mcp_server_names.any?
540
544
 
541
- # Robot can now use filesystem tools — :inherit is required on the run
545
+ # run() defaults to mcp: :none / tools: :none — ask for the servers and their tools per run
542
546
  result = robot.run("List the files in the current directory", mcp: :inherit, tools: :inherit)
547
+
548
+ # The full ruby_llm surface is one lookup away
549
+ robot.mcp[:filesystem].resources
550
+ robot.run(robot.mcp[:git_hub].prompt("code_review", code: diff), mcp: :inherit)
551
+
552
+ robot.disconnect # closes every server (stopping stdio processes); they reconnect on next use
543
553
  ```
544
554
 
545
- MCP connections are resilient: failed servers are automatically retried on subsequent `run(mcp: :inherit)` calls, and one failing server does not prevent others from connecting. Connection failures are logged and recorded in `failed_mcp_server_names` rather than raised, so a robot whose servers all failed still builds and runs — just without those tools.
555
+ Connection failures are logged, recorded in `robot.mcp_failures` and retried on the next run that selects the server; they are not raised, and one failing server does not stop the others. A `RubyLLM::MCP::Error` during a tool call becomes an `{ error: }` tool result rather than aborting the run. See the [MCP Integration guide](docs/guides/mcp-integration.md), including its "Migrating from 0.3" section.
546
556
 
547
557
  ## Message Bus
548
558
 
@@ -28,6 +28,10 @@ module RobotLab
28
28
  error.respond_to?(:retryable) && error.retryable == true
29
29
  when RobotLab::InferenceError
30
30
  true
31
+ when RubyLLM::MCP::Error
32
+ # A JSON-RPC error code means the server answered and rejected the
33
+ # request; no code means the transport failed (timeout, closed pipe).
34
+ error.code.nil?
31
35
  else
32
36
  false
33
37
  end
@@ -74,10 +74,11 @@ module RobotLab
74
74
  end
75
75
 
76
76
  class ToolCallHookContext < HookContext
77
- attr_reader :tool, :tool_name, :tool_args, :robot
77
+ attr_reader :tool, :tool_name, :tool_args, :robot, :mcp_server
78
78
  attr_accessor :tool_result, :tool_error
79
79
 
80
- def initialize(tool:, tool_args:, robot: nil, tool_result: nil, tool_error: nil, **)
80
+ # @param mcp_server [RubyLLM::MCP, nil] the server that owns +tool+ when it is an MCP tool
81
+ def initialize(tool:, tool_args:, robot: nil, tool_result: nil, tool_error: nil, mcp_server: nil, **)
81
82
  super(event: :tool_call, **)
82
83
  @tool = tool
83
84
  @tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
@@ -85,6 +86,12 @@ module RobotLab
85
86
  @robot = robot
86
87
  @tool_result = tool_result
87
88
  @tool_error = tool_error
89
+ @mcp_server = mcp_server
90
+ end
91
+
92
+ # @return [Boolean] whether the tool being called comes from an MCP server
93
+ def mcp?
94
+ !@mcp_server.nil?
88
95
  end
89
96
  end
90
97
 
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module MCP
5
+ # The MCP servers a robot knows, keyed by server name.
6
+ #
7
+ # A robot resolves its +mcp+ config on every run. The registry makes that
8
+ # cheap and side-effect free: adding the same config Hash (by value), the
9
+ # same +RubyLLM::MCP+ instance, or the same class again returns the server
10
+ # already built, so a stdio server's process is started once and reused
11
+ # across runs. Adding a different spec under an existing name closes the
12
+ # old server and replaces it.
13
+ #
14
+ # Servers are closed only by {#delete}, {#clear}, or the robot's
15
+ # +disconnect+; selecting a subset of servers for one run never closes
16
+ # the others.
17
+ class Registry
18
+ include Enumerable
19
+
20
+ def initialize
21
+ @servers = {}
22
+ @specs = {}
23
+ end
24
+
25
+ # Register a server (or return the one already registered for +spec+).
26
+ #
27
+ # @param spec [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
28
+ # @return [RubyLLM::MCP]
29
+ def add(spec)
30
+ key = MCP.name_of(spec).to_sym
31
+ existing = @servers[key]
32
+ return existing if existing && same_spec?(key, spec, existing)
33
+
34
+ existing&.close
35
+ @specs[key] = spec
36
+ @servers[key] = MCP.build(spec)
37
+ end
38
+
39
+ # @param name [String, Symbol]
40
+ # @return [RubyLLM::MCP, nil]
41
+ def [](name)
42
+ @servers[name.to_sym]
43
+ end
44
+
45
+ def key?(name)
46
+ @servers.key?(name.to_sym)
47
+ end
48
+
49
+ def each(&)
50
+ @servers.each_value(&)
51
+ end
52
+
53
+ # @return [Array<String>] registered server names, in registration order
54
+ def names
55
+ @servers.keys.map(&:to_s)
56
+ end
57
+
58
+ def empty?
59
+ @servers.empty?
60
+ end
61
+
62
+ def size
63
+ @servers.size
64
+ end
65
+
66
+ # Close and forget one server.
67
+ #
68
+ # @param name [String, Symbol]
69
+ # @return [RubyLLM::MCP, nil] the removed server
70
+ def delete(name)
71
+ key = name.to_sym
72
+ @specs.delete(key)
73
+ server = @servers.delete(key)
74
+ server&.close
75
+ server
76
+ end
77
+
78
+ # Close and forget every server.
79
+ #
80
+ # @return [self]
81
+ def clear
82
+ each(&:close)
83
+ @servers.clear
84
+ @specs.clear
85
+ self
86
+ end
87
+
88
+ private
89
+
90
+ def same_spec?(key, spec, existing)
91
+ case spec
92
+ when RubyLLM::MCP then existing.equal?(spec)
93
+ when Hash then @specs[key] == spec
94
+ else @specs[key].equal?(spec)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -2,105 +2,69 @@
2
2
 
3
3
  module RobotLab
4
4
  module MCP
5
- # Configuration for an MCP server connection
5
+ # The +RubyLLM::MCP+ subclass that {MCP.build} creates from a config Hash.
6
6
  #
7
- # @example WebSocket transport
8
- # Server.new(
9
- # name: "neon",
10
- # transport: { type: "ws", url: "ws://localhost:8080" }
11
- # )
7
+ # Everything a server can do comes from ruby_llm: +tools+, +call+,
8
+ # +resources+, +resource+, +prompts+, +prompt+, +instructions+, +version+,
9
+ # +listen+, +close+, the OAuth flow, and so on. This subclass adds only:
12
10
  #
13
- # @example StdIO transport
14
- # Server.new(
15
- # name: "filesystem",
16
- # transport: {
17
- # type: "stdio",
18
- # command: "mcp-server-filesystem",
19
- # args: ["--root", "/data"]
20
- # }
21
- # )
11
+ # - a class-level +description+, used by {ServerDiscovery} to pick servers
12
+ # relevant to a message;
13
+ # - +connected?+, true once the server has answered a +tools+ request and
14
+ # false again after +close+.
22
15
  #
23
- class Server
24
- # Valid transport types for MCP connections
25
- VALID_TRANSPORT_TYPES = %w[stdio sse ws websocket streamable-http http].freeze
16
+ # Subclass it directly when you want the description macro:
17
+ #
18
+ # class Brew < RobotLab::MCP::Server
19
+ # description "Install, update, and manage macOS packages via Homebrew"
20
+ # command "mcp-server-brew"
21
+ # end
22
+ # :reek:InstanceVariableAssumption -- @description is a class-level DSL ivar set by the description macro
23
+ # and copied by inherited; it has no instance-side initializer by design.
24
+ class Server < RubyLLM::MCP
25
+ @description = nil
26
26
 
27
- # Default timeout for MCP requests (in seconds)
28
- DEFAULT_TIMEOUT = 15
27
+ class << self
28
+ # Sets or reads a short description of what the server offers.
29
+ #
30
+ # @param value [String, nil]
31
+ # @return [String, nil]
32
+ def description(value = nil)
33
+ return @description if value.nil?
29
34
 
30
- # @!attribute [r] name
31
- # @return [String] the server name
32
- # @!attribute [r] transport
33
- # @return [Hash] the transport configuration
34
- # @!attribute [r] timeout
35
- # @return [Numeric] request timeout in seconds
36
- # @!attribute [r] description
37
- # @return [String] human-readable description used by ServerDiscovery
38
- attr_reader :name, :transport, :timeout, :description
35
+ @description = value.to_s
36
+ end
39
37
 
40
- # Creates a new Server configuration.
41
- #
42
- # @param name [String] the server name
43
- # @param transport [Hash] the transport configuration
44
- # @param timeout [Numeric, nil] request timeout in seconds (default: 15)
45
- # @param description [String, nil] human-readable description for server discovery
46
- # @param _extra [Hash] additional keys are silently ignored for forward compatibility
47
- # @raise [ArgumentError] if transport type is invalid or required fields are missing
48
- def initialize(name:, transport:, timeout: nil, description: nil, **_extra)
49
- @name = name.to_s
50
- @description = description.to_s
51
- @transport = normalize_transport(transport)
52
- @timeout = normalize_timeout(timeout)
53
- validate!
38
+ def inherited(subclass) # :nodoc:
39
+ super
40
+ subclass.description(@description) if @description
41
+ end
54
42
  end
55
43
 
56
- # Returns the transport type.
57
- #
58
- # @return [String] the transport type (stdio, sse, ws, etc.)
59
- def transport_type
60
- @transport[:type]
44
+ def initialize(context: nil, **inputs)
45
+ super
46
+ @connected = false
61
47
  end
62
48
 
63
- # Converts the server configuration to a hash.
64
- #
65
- # @return [Hash]
66
- def to_h
67
- {
68
- name: name,
69
- description: description,
70
- transport: transport,
71
- timeout: timeout
72
- }
49
+ # @return [String] the class-level description, or an empty string
50
+ def description
51
+ self.class.description.to_s
73
52
  end
74
53
 
75
- private
76
-
77
- def normalize_transport(transport)
78
- transport = transport.transform_keys(&:to_sym)
79
- transport[:type] = transport[:type].to_s.downcase
80
- transport
54
+ # @return [Boolean] whether the server has answered a tools request since the last close
55
+ def connected?
56
+ @connected == true
81
57
  end
82
58
 
83
- def validate!
84
- unless VALID_TRANSPORT_TYPES.include?(transport_type)
85
- raise ArgumentError, "Invalid transport type: #{transport_type}. " \
86
- "Must be one of: #{VALID_TRANSPORT_TYPES.join(', ')}"
87
- end
88
-
89
- case transport_type
90
- when "stdio"
91
- raise ArgumentError, "StdIO transport requires :command" unless transport[:command]
92
- when "ws", "websocket", "sse", "streamable-http", "http"
93
- raise ArgumentError, "Transport requires :url" unless transport[:url]
94
- end
59
+ # Lists the server's tools, connecting on the first call (see RubyLLM::MCP#tools).
60
+ def tools
61
+ super.tap { @connected = true }
95
62
  end
96
63
 
97
- def normalize_timeout(value)
98
- return DEFAULT_TIMEOUT if value.nil?
99
-
100
- seconds = value.to_f
101
- # If the caller passed milliseconds (>= 1000), convert to seconds
102
- seconds /= 1000.0 if seconds >= 1000
103
- [seconds, 1].max
64
+ # Closes the connection (see RubyLLM::MCP#close). The next request reconnects.
65
+ def close
66
+ @connected = false
67
+ super
104
68
  end
105
69
  end
106
70
  end
@@ -56,9 +56,9 @@ module RobotLab
56
56
  # Select MCP servers relevant to the given query.
57
57
  #
58
58
  # @param query [String] user message or intent
59
- # @param from [Array<Hash, MCP::Server>] candidate server configs
59
+ # @param from [Array<Hash, RubyLLM::MCP, Class>] candidate server specs
60
60
  # @param threshold [Float] minimum cosine score (default 0.05)
61
- # @return [Array<Hash, MCP::Server>] matching servers, or +from+ as
61
+ # @return [Array<Hash, RubyLLM::MCP, Class>] matching servers, or +from+ as
62
62
  # fallback when no match is found
63
63
  # :reek:TooManyStatements -- linear guard/score/filter pipeline; each early return is a documented fallback.
64
64
  def self.select(query, from:, threshold: DEFAULT_THRESHOLD)
@@ -77,29 +77,42 @@ module RobotLab
77
77
  from
78
78
  end
79
79
 
80
- # @param servers [Array<Hash, MCP::Server>]
80
+ # @param servers [Array<Hash, RubyLLM::MCP, Class>]
81
81
  def self.any_descriptions?(servers)
82
82
  servers.any? { |s| !description_for(s).empty? }
83
83
  end
84
84
 
85
85
  # Build the topic string used for similarity scoring: name + description.
86
86
  #
87
- # @param server [Hash, MCP::Server]
87
+ # @param server [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
88
88
  # @return [String]
89
89
  def self.topic_text(server)
90
- name = server.is_a?(Hash) ? server[:name].to_s : server.name.to_s
91
- "#{name} #{description_for(server)}".strip
90
+ "#{name_for(server)} #{description_for(server)}".strip
92
91
  end
93
92
 
94
- # @param server [Hash, MCP::Server]
93
+ # @param server [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
94
+ # @return [String] server name or empty string
95
+ def self.name_for(server)
96
+ name = case server
97
+ when Hash then server[:name] || server['name']
98
+ when Class then server.default_name
99
+ else server.name
100
+ end
101
+ name.to_s.strip
102
+ end
103
+
104
+ # @param server [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
95
105
  # @return [String] description or empty string
96
106
  def self.description_for(server)
97
- desc = server.is_a?(Hash) ? server[:description] : server.respond_to?(:description) && server.description
107
+ desc = case server
108
+ when Hash then server[:description] || server['description']
109
+ else (server.description if server.respond_to?(:description))
110
+ end
98
111
  desc.to_s.strip
99
112
  end
100
113
 
101
114
  # @param query [String]
102
- # @param server [Hash, MCP::Server]
115
+ # @param server [Hash, RubyLLM::MCP, Class]
103
116
  # @return [Float] cosine similarity in [0.0, 1.0]
104
117
  def self.score(query, server)
105
118
  TextAnalysis.tf_cosine_similarity(query, topic_text(server))