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
data/examples/xyzzy.rb DELETED
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # xyzzy.rb — single-file RobotLab hook extension demo
4
- #
5
- # Demonstrates the Hook handler class pattern: implement class methods for
6
- # every hook under a dedicated namespace and log each callback with its
7
- # context snapshot. Useful as a live trace of the full hook pipeline during
8
- # development.
9
- #
10
- # Fully Ractor-safe: no class-level mutable state. LOG_PATH is a frozen
11
- # string constant (shareable); each call opens its own file handle.
12
- #
13
- # stdout : one tagline per hook call → [xyzzy] HH:MM:SS.mmm hook_name
14
- # logfile: full context snapshot written to LOG_PATH via PP.pp
15
- #
16
- # The log destination is read from XYZZY_LOG_PATH at load time and frozen
17
- # into a constant. Set it BEFORE requiring this file to redirect the log;
18
- # there is deliberately no writer method, so the constant stays shareable.
19
- #
20
- # Usage (from any example that requires common):
21
- # ENV["XYZZY_LOG_PATH"] = "/tmp/xyzzy.log" # optional, must precede require
22
- # require_relative "xyzzy"
23
-
24
- require "pp"
25
- require "fileutils"
26
-
27
- module RobotLab
28
- class Xyzzy < Hook
29
- self.namespace = :xyzzy
30
-
31
- LOG_PATH = File.expand_path(
32
- ENV.fetch("XYZZY_LOG_PATH", "~/.robot_lab/xyzzy_hooks.log")
33
- ).freeze
34
- FileUtils.mkdir_p(File.dirname(LOG_PATH))
35
-
36
- class << self
37
- def before_run(ctx) = log_hook(:before_run, ctx)
38
- def after_run(ctx) = log_hook(:after_run, ctx)
39
- def on_error(ctx) = log_hook(:on_error, ctx)
40
- def before_llm_generation(ctx) = log_hook(:before_llm_generation, ctx)
41
- def after_llm_generation(ctx) = log_hook(:after_llm_generation, ctx)
42
- def before_tool_call(ctx) = log_hook(:before_tool_call, ctx)
43
- def after_tool_call(ctx) = log_hook(:after_tool_call, ctx)
44
- def before_network_run(ctx) = log_hook(:before_network_run, ctx)
45
- def after_network_run(ctx) = log_hook(:after_network_run, ctx)
46
- def before_task(ctx) = log_hook(:before_task, ctx)
47
- def after_task(ctx) = log_hook(:after_task, ctx)
48
-
49
- def around_run(ctx, &block)
50
- log_hook(:around_run, ctx)
51
- block.call
52
- end
53
-
54
- def around_llm_generation(ctx, &block)
55
- log_hook(:around_llm_generation, ctx)
56
- block.call
57
- end
58
-
59
- def around_tool_call(ctx, &block)
60
- log_hook(:around_tool_call, ctx)
61
- block.call
62
- end
63
-
64
- def around_network_run(ctx, &block)
65
- log_hook(:around_network_run, ctx)
66
- block.call
67
- end
68
-
69
- def around_task(ctx, &block)
70
- log_hook(:around_task, ctx)
71
- block.call
72
- end
73
-
74
- private
75
-
76
- def log_hook(hook_name, ctx)
77
- ts = Time.now.strftime('%H:%M:%S.%3N')
78
- snapshot = context_snapshot(ctx)
79
- $stdout.puts " [xyzzy] #{ts} #{hook_name}"
80
- File.open(LOG_PATH, "a") { |f| f.puts "#{ts} #{hook_name} | #{PP.pp(snapshot, +"").chomp}" }
81
- end
82
-
83
- def context_snapshot(ctx)
84
- {
85
- robot: ctx.respond_to?(:robot) ? ctx.robot&.name : nil,
86
- request: ctx.respond_to?(:request) ? ctx.request : nil,
87
- network: ctx.respond_to?(:network) ? ctx.network&.name : nil,
88
- task: ctx.respond_to?(:task) ? ctx.task : nil,
89
- error: ctx.respond_to?(:error) ? ctx.error&.message : nil
90
- }.compact
91
- end
92
- end
93
- end
94
- end
95
-
96
- RobotLab.register_extension(:xyzzy, RobotLab::Xyzzy) if RobotLab.respond_to?(:register_extension)
97
- RobotLab.on(RobotLab::Xyzzy)
@@ -1,223 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- # MCP client for communicating with Model Context Protocol servers
6
- #
7
- # Uses actionmcp gem for MCP protocol implementation.
8
- # Supports multiple transport types: StdIO, SSE, WebSocket, HTTP.
9
- #
10
- # @example
11
- # client = Client.new(name: "neon", transport: { type: "ws", url: "ws://..." })
12
- # client.connect
13
- # tools = client.list_tools
14
- # result = client.call_tool("createBranch", { project_id: "abc" })
15
- #
16
- # :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every public operation must check it.
17
- class Client
18
- # @!attribute [r] server
19
- # @return [Server] the MCP server configuration
20
- # @!attribute [r] connected
21
- # @return [Boolean] whether currently connected
22
- attr_reader :server, :connected, :transport
23
-
24
- # Creates a new MCP Client instance.
25
- #
26
- # @param server_or_config [Server, Hash] the server or configuration hash
27
- # @param poller [MCP::ConnectionPoller, nil] optional shared IO.select poller
28
- # for multiplexing multiple stdio transports (opt-in, default nil = per-client blocking)
29
- # @raise [ArgumentError] if config is invalid
30
- def initialize(server_or_config, poller: nil)
31
- @server = case server_or_config
32
- when Server
33
- server_or_config
34
- when Hash
35
- Server.new(**server_or_config.transform_keys(&:to_sym))
36
- else
37
- raise ArgumentError, "Invalid server config"
38
- end
39
- @connected = false
40
- @transport = nil
41
- @request_id = 0
42
- @poller = poller
43
- end
44
-
45
- # Connect to the MCP server
46
- #
47
- # @return [self]
48
- #
49
- # :reek:TooManyStatements -- linear connect/register sequence with a best-effort rescue.
50
- def connect
51
- return self if @connected
52
-
53
- @transport = create_transport
54
- @transport.connect if @transport.respond_to?(:connect)
55
- @connected = true
56
-
57
- # Register with shared poller after the transport is connected
58
- @poller.register(self) if @poller
59
-
60
- self
61
- rescue StandardError => e
62
- RobotLab.config.logger.warn("MCP connection failed for #{@server.name}: #{e.message}")
63
- @connected = false
64
- self
65
- end
66
-
67
- # Disconnect from the server
68
- #
69
- # @return [self]
70
- #
71
- def disconnect
72
- return self unless @connected
73
-
74
- @poller.unregister(self) if @poller
75
- @transport.close if @transport.respond_to?(:close)
76
- @connected = false
77
- @transport = nil
78
-
79
- self
80
- end
81
-
82
- # List available tools from the server
83
- #
84
- # @return [Array<Hash>] Tool definitions
85
- #
86
- def list_tools
87
- ensure_connected!
88
- response = request(method: "tools/list")
89
- response[:tools] || []
90
- end
91
-
92
- # Call a tool on the server
93
- #
94
- # @param name [String] Tool name
95
- # @param arguments [Hash] Tool arguments
96
- # @return [Object] Tool result
97
- #
98
- def call_tool(name, arguments = {})
99
- ensure_connected!
100
- response = request(
101
- method: "tools/call",
102
- params: { name: name, arguments: arguments }
103
- )
104
- response[:content] || response
105
- end
106
-
107
- # List available resources
108
- #
109
- # @return [Array<Hash>]
110
- #
111
- def list_resources
112
- ensure_connected!
113
- response = request(method: "resources/list")
114
- response[:resources] || []
115
- end
116
-
117
- # Read a resource
118
- #
119
- # @param uri [String] Resource URI
120
- # @return [Object]
121
- #
122
- def read_resource(uri)
123
- ensure_connected!
124
- response = request(method: "resources/read", params: { uri: uri })
125
- response[:contents] || response
126
- end
127
-
128
- # List available prompts
129
- #
130
- # @return [Array<Hash>]
131
- #
132
- def list_prompts
133
- ensure_connected!
134
- response = request(method: "prompts/list")
135
- response[:prompts] || []
136
- end
137
-
138
- # Get a prompt
139
- #
140
- # @param name [String] Prompt name
141
- # @param arguments [Hash] Prompt arguments
142
- # @return [Hash]
143
- #
144
- def get_prompt(name, arguments = {})
145
- ensure_connected!
146
- request(method: "prompts/get", params: { name: name, arguments: arguments })
147
- end
148
-
149
- # Checks if the client is connected to the server.
150
- #
151
- # @return [Boolean]
152
- def connected?
153
- @connected
154
- end
155
-
156
- # Converts the client to a hash representation.
157
- #
158
- # @return [Hash]
159
- def to_h
160
- {
161
- server: @server.to_h,
162
- connected: @connected
163
- }
164
- end
165
-
166
- private
167
-
168
- def ensure_connected!
169
- raise MCPError, "Not connected to MCP server: #{@server.name}" unless @connected
170
- end
171
-
172
- def create_transport
173
- config = @server.transport.merge(timeout: @server.timeout)
174
-
175
- case @server.transport_type
176
- when "stdio"
177
- Transports::Stdio.new(config)
178
- when "ws", "websocket"
179
- Transports::WebSocket.new(config)
180
- when "sse"
181
- Transports::SSE.new(config)
182
- when "streamable-http", "http"
183
- Transports::StreamableHTTP.new(config)
184
- else
185
- raise MCPError, "Unsupported transport type: #{@server.transport_type}"
186
- end
187
- end
188
-
189
- def request(method:, params: nil)
190
- @request_id += 1
191
-
192
- message = {
193
- jsonrpc: "2.0",
194
- id: @request_id,
195
- method: method
196
- }
197
- message[:params] = params if params
198
-
199
- response = if @poller && @transport.is_a?(Transports::Stdio)
200
- @poller.send_request(self, message, timeout: @server.timeout)
201
- else
202
- @transport.send_request(message)
203
- end
204
-
205
- parse_response(response)
206
- end
207
-
208
- def parse_response(response)
209
- case response
210
- when String
211
- parsed = JSON.parse(response, symbolize_names: true)
212
- parsed[:result] || parsed
213
- when Hash
214
- response[:result] || response
215
- else
216
- response
217
- end
218
- rescue JSON::ParserError
219
- { raw: response }
220
- end
221
- end
222
- end
223
- end
@@ -1,195 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- # Multiplexes I/O across multiple stdio MCP transports.
6
- #
7
- # When a robot connects to more than one local (stdio) MCP server,
8
- # the default approach blocks independently per client, each with its
9
- # own Timeout.timeout wrapper. ConnectionPoller replaces this with a
10
- # single IO.select call across all registered stdout file descriptors,
11
- # dispatching each response to the pending request for that client.
12
- #
13
- # Async-based transports (SSE, WebSocket, StreamableHTTP) are
14
- # unaffected — they already use the Async fiber scheduler.
15
- #
16
- # == Usage
17
- #
18
- # poller = MCP::ConnectionPoller.new.start
19
- # client = MCP::Client.new(server_config, poller: poller)
20
- # client.connect # registers transport with poller
21
- # client.call_tool(…)
22
- # poller.stop
23
- #
24
- # @api private
25
- class ConnectionPoller
26
- POLL_INTERVAL = 0.1 # seconds between IO.select checks
27
-
28
- # Creates a new ConnectionPoller.
29
- def initialize
30
- @mutex = Mutex.new
31
- @clients = {} # IO => { client:, queue: Thread::Queue }
32
- @running = false
33
- @thread = nil
34
- end
35
-
36
- # Start the multiplexing thread.
37
- #
38
- # @return [self]
39
- def start
40
- @mutex.synchronize do
41
- return self if @running
42
-
43
- @running = true
44
- @thread = Thread.new { poll_loop }
45
- @thread.name = "RobotLab::MCP::ConnectionPoller"
46
- end
47
- self
48
- end
49
-
50
- # Stop the multiplexing thread.
51
- #
52
- # Cancels all pending requests with an MCPError.
53
- #
54
- # @param timeout [Numeric] seconds to wait for thread to finish
55
- # @return [self]
56
- def stop(timeout: 5)
57
- @mutex.synchronize do
58
- return self unless @running
59
-
60
- @running = false
61
-
62
- # Unblock any pending request queues
63
- @clients.each_value do |entry|
64
- entry[:queue]&.push({ error: "ConnectionPoller stopped" })
65
- end
66
- end
67
-
68
- @thread&.join(timeout)
69
- @thread = nil
70
- self
71
- end
72
-
73
- # Register an MCP client with the poller.
74
- #
75
- # Only stdio clients are registered — others are silently ignored.
76
- #
77
- # @param client [MCP::Client]
78
- # @return [void]
79
- def register(client)
80
- return unless stdio_client?(client)
81
-
82
- io = client.transport.stdout
83
- @mutex.synchronize { @clients[io] = { client: client, queue: nil } }
84
- end
85
-
86
- # Unregister a client.
87
- #
88
- # @param client [MCP::Client]
89
- # @return [void]
90
- def unregister(client)
91
- return unless stdio_client?(client)
92
-
93
- io = client.transport.stdout
94
- @mutex.synchronize { @clients.delete(io) }
95
- end
96
-
97
- # Send a JSON-RPC request via the poller and block until the response.
98
- #
99
- # Writes to the client's stdin, registers a response queue, then
100
- # blocks until the poll loop dispatches the response.
101
- #
102
- # @param client [MCP::Client]
103
- # @param message [Hash] JSON-RPC message
104
- # @param timeout [Numeric] seconds before raising MCPError
105
- # @return [Hash] parsed response
106
- # @raise [MCPError] on timeout or connection error
107
- # :reek:TooManyStatements -- register/write/wait/cleanup must stay in one method so ensure releases the queue.
108
- # :reek:DuplicateMethodCall -- the repeated @mutex.synchronize/@clients[io] pairs are deliberately separate short critical
109
- # sections; holding the lock across the blocking write/wait would deadlock the poll loop.
110
- def send_request(client, message, timeout:)
111
- transport = client.transport
112
- io = transport.stdout
113
- stdin = transport.stdin
114
- queue = Thread::Queue.new
115
-
116
- @mutex.synchronize { @clients[io][:queue] = queue }
117
-
118
- begin
119
- stdin.puts(message.to_json)
120
- stdin.flush
121
- rescue Errno::EPIPE, IOError => e
122
- @mutex.synchronize { @clients[io][:queue] = nil }
123
- raise MCPError.new("MCP connection lost: #{e.message}", retryable: true)
124
- end
125
-
126
- response = Timeout.timeout(timeout) { queue.pop }
127
-
128
- if response.is_a?(Hash) && response[:error]
129
- raise MCPError, response[:error]
130
- end
131
-
132
- response
133
- rescue Timeout::Error
134
- @mutex.synchronize { @clients[io]&.[]= :queue, nil }
135
- raise MCPError.new("MCP server did not respond within #{timeout}s", retryable: true)
136
- ensure
137
- @mutex.synchronize { @clients[io]&.[]= :queue, nil }
138
- end
139
-
140
- # Whether the poller thread is running.
141
- #
142
- # @return [Boolean]
143
- def running?
144
- @mutex.synchronize { @running }
145
- end
146
-
147
- private
148
-
149
- def poll_loop
150
- loop do
151
- ios = @mutex.synchronize { @clients.keys.reject(&:closed?) }
152
-
153
- if ios.empty?
154
- sleep POLL_INTERVAL
155
- else
156
- begin
157
- ready, = IO.select(ios, nil, nil, POLL_INTERVAL)
158
- dispatch(ready) if ready
159
- rescue Errno::EBADF
160
- # A pipe was closed between the reject and IO.select — harmless, loop again
161
- end
162
- end
163
-
164
- break unless @mutex.synchronize { @running }
165
- end
166
- end
167
-
168
- # :reek:TooManyStatements -- read/parse/filter/route steps for each readable IO; each guard skips a non-response line.
169
- def dispatch(readable_ios)
170
- readable_ios.each do |io|
171
- line = io.gets rescue nil
172
- next unless line
173
-
174
- parsed = JSON.parse(line, symbolize_names: true) rescue nil
175
- next unless parsed
176
-
177
- # Skip notifications (method present, no id)
178
- next if parsed[:method] && !parsed.key?(:id)
179
-
180
- entry = @mutex.synchronize { @clients[io] }
181
- entry[:queue]&.push(parsed)
182
- end
183
- end
184
-
185
- def stdio_client?(client)
186
- return false unless client.respond_to?(:transport)
187
-
188
- transport = client.transport
189
- transport.is_a?(Transports::Stdio) &&
190
- transport.respond_to?(:stdout) &&
191
- !transport.stdout.nil?
192
- end
193
- end
194
- end
195
- end
@@ -1,64 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- module Transports
6
- # Base class for MCP transports
7
- #
8
- # @abstract Subclass and implement {#connect}, {#send_request}, {#close}
9
- #
10
- class Base
11
- # Default timeout for request operations (in seconds)
12
- DEFAULT_TIMEOUT = 15
13
-
14
- # @!attribute [r] config
15
- # @return [Hash] the transport configuration
16
- # @!attribute [r] timeout
17
- # @return [Numeric] request timeout in seconds
18
- attr_reader :config, :timeout
19
-
20
- # Creates a new transport instance.
21
- #
22
- # @param config [Hash] transport configuration options.
23
- # May include a :timeout key (in seconds) that controls how long
24
- # blocking operations wait before raising an error.
25
- def initialize(config)
26
- @config = config.transform_keys(&:to_sym)
27
- @timeout = @config.delete(:timeout) || DEFAULT_TIMEOUT
28
- end
29
-
30
- # Connect to the server
31
- #
32
- # @return [self]
33
- #
34
- def connect
35
- raise NotImplementedError
36
- end
37
-
38
- # Send a JSON-RPC request
39
- #
40
- # @param message [Hash] JSON-RPC message
41
- # @return [Hash] Response
42
- #
43
- def send_request(message)
44
- raise NotImplementedError
45
- end
46
-
47
- # Close the connection
48
- #
49
- # @return [self]
50
- #
51
- def close
52
- raise NotImplementedError
53
- end
54
-
55
- # Check if the transport is connected.
56
- #
57
- # @return [Boolean] true if connected
58
- def connected?
59
- false
60
- end
61
- end
62
- end
63
- end
64
- end
@@ -1,120 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module MCP
5
- module Transports
6
- # Server-Sent Events transport for MCP servers
7
- #
8
- # Uses async-http for SSE streaming.
9
- #
10
- # @example
11
- # transport = SSE.new(url: "http://localhost:8080/sse")
12
- #
13
- # :reek:InstanceVariableAssumption -- @config is assigned in Base#initialize (reek does not trace super).
14
- # :reek:RepeatedConditional -- @connected is the connection-lifecycle guard; every operation must check it.
15
- class SSE < Base
16
- # Creates a new SSE transport.
17
- #
18
- # @param config [Hash] transport configuration
19
- # @option config [String] :url SSE server URL
20
- def initialize(config)
21
- super
22
- @client = nil
23
- @connected = false
24
- @event_queue = []
25
- end
26
-
27
- # Connect to the MCP server via SSE.
28
- #
29
- # @return [self]
30
- # @raise [MCPError] if async-http gem is not available
31
- # :reek:TooManyStatements -- linear require/connect/handshake sequence inside the Async block.
32
- def connect
33
- return self if @connected
34
-
35
- require "async"
36
- require "async/http/client"
37
- require "async/http/endpoint"
38
-
39
- url = @config[:url]
40
-
41
- Async do
42
- endpoint = Async::HTTP::Endpoint.parse(url)
43
- @client = Async::HTTP::Client.new(endpoint)
44
- @connected = true
45
-
46
- # Initialize MCP protocol
47
- send_initialize
48
- end
49
-
50
- self
51
- rescue LoadError => e
52
- raise MCPError, "async-http gem required for SSE transport: #{e.message}"
53
- end
54
-
55
- # Send a JSON-RPC request to the MCP server.
56
- #
57
- # @param message [Hash] JSON-RPC message
58
- # @return [Hash] the response
59
- # @raise [MCPError] if not connected
60
- def send_request(message)
61
- raise MCPError, "Not connected" unless @connected
62
-
63
- require "async"
64
- require "async/http/body/writable"
65
-
66
- Async do
67
- # POST the request
68
- response = @client.post(
69
- @config[:url],
70
- { "Content-Type" => "application/json" },
71
- [message.to_json]
72
- )
73
-
74
- # Read response
75
- body = response.read
76
- JSON.parse(body, symbolize_names: true)
77
- end.wait
78
- end
79
-
80
- # Close the SSE connection.
81
- #
82
- # @return [self]
83
- def close
84
- return self unless @connected
85
-
86
- @client&.close
87
- @connected = false
88
- @client = nil
89
-
90
- self
91
- end
92
-
93
- # Check if the transport is connected.
94
- #
95
- # @return [Boolean] true if connected
96
- def connected?
97
- @connected
98
- end
99
-
100
- private
101
-
102
- def send_initialize
103
- send_request(
104
- jsonrpc: "2.0",
105
- id: 0,
106
- method: "initialize",
107
- params: {
108
- protocolVersion: "2024-11-05",
109
- capabilities: {},
110
- clientInfo: {
111
- name: "RobotLab",
112
- version: RobotLab::VERSION
113
- }
114
- }
115
- )
116
- end
117
- end
118
- end
119
- end
120
- end