robot_lab 0.3.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ae306b218ea8c7c5d68b9de5c0bce088e40a9f1c482278fd54557624bc0ad72
4
- data.tar.gz: 92206741b254631a7e09da7247db35702eb12a70409622aa3634c594e83207a0
3
+ metadata.gz: 7830657dbfe2508c38884f5a117e3b1eb6db6223c0c5336ba8fdbdd3c8718732
4
+ data.tar.gz: 3df367453aef2881e24e7e718c7778416e932e4f265deca27961bbe1952db483
5
5
  SHA512:
6
- metadata.gz: 36c52283dfd0edc88cfc71e358a82e463ddfad501a69097ef2aaa28b37f7c5f5d08938d39b1c6312c2b1a3aaf19494895d3bcfd6f74870b4b770942f3ae60d3e
7
- data.tar.gz: 1477bf01cb700d41bab40950cd650960676f1589a5f2f4dc8747b1f940e9f21a5b3ff53a15ca4aa2ef01c493b38c1f957406ddc1b365f1592a21b1e6bda1d992
6
+ metadata.gz: 5f12198c217fa3465274de33a0dfac869eecc51e679f5f65d6d0ee3f0ad18ce9cb14e346b12f35fff6f0fc948ec8dc19e2dfc4eae66afd94a6e4f469a9d2458b
7
+ data.tar.gz: 7e91d8448bc45512216c1e53ba4dee007c0f40f225a6a34b1b8a51238962b99fd6b26757a6dc842d332ed34ccc73f7a381e404d622ad1e847bfeff5da29c1df0
data/CHANGELOG.md CHANGED
@@ -8,6 +8,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Fixed
12
+
13
+ - **OAuth `grant:` from YAML.** An MCP server config such as `oauth: { grant: client_credentials }` raised `ArgumentError: Unknown OAuth grant`, because ruby_llm accepts the grant only as a Symbol. `RobotLab::MCP.define` now converts a String grant to a Symbol.
14
+
15
+ ## [0.5.0] - 2026-11-01
16
+
17
+ robot_lab 0.4.0: MCP (Model Context Protocol) now runs on ruby_llm 2.1's
18
+ built-in client. robot_lab's own JSON-RPC client, four transports and
19
+ connection poller are gone; a robot's MCP servers are `RubyLLM::MCP`
20
+ objects with the whole protocol surface (tools, resources, prompts, OAuth,
21
+ input requests, long-running tasks, deferred tools), not just tools. This
22
+ is a deliberate breaking release with no legacy API; see "Migrating from
23
+ 0.3" in `docs/guides/mcp-integration.md`.
24
+
25
+ ### Changed (BREAKING)
26
+
27
+ - **ruby_llm 2.1.1 or newer is required** (`ruby_llm ">= 2.1.1", "< 3"`). The `async-http` and `async-websocket` dependencies are dropped.
28
+ - **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:`.
29
+ - **`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.
30
+ - **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.
31
+ - **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.
32
+ - **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.
33
+ - **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.
34
+ - **`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.
35
+ - `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.
36
+ - `RobotLab::Tool.create` no longer takes `mcp:`; `Tool#mcp`, `Tool#mcp?` and the `:mcp` key of `Tool#to_h` are removed.
37
+ - `MCP::ServerDiscovery` runs on every message (not only the first run) and accepts Hashes with symbol or string keys, `RubyLLM::MCP` instances and subclasses.
38
+
39
+ ### Added
40
+
41
+ - `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.
42
+ - `RobotLab::MCP::Server < RubyLLM::MCP` with a `description` class macro (used by `ServerDiscovery`) and `#connected?`.
43
+ - `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.
44
+ - `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.
45
+ - `Robot#run` accepts a `RubyLLM::MCP::Prompt` (`robot.run(robot.mcp[:github].prompt("code_review", code: diff))`).
46
+ - `: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.
47
+ - `Robot#mcp_failures`, `#mcp_tools(servers)`, `#mcp_server_for(tool)`; servers' deferred tools (`defer:`) are attached with `defer: true`.
48
+ - `RobotLab::Errors.retryable?` treats a `RubyLLM::MCP::Error` without a JSON-RPC code (transport failure) as retryable and one with a code as not.
49
+ - `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.
50
+
51
+ ### Removed
52
+
53
+ - `RobotLab::MCP::Client`, `RobotLab::MCP::ConnectionPoller`, `RobotLab::MCP::Transports::{Base,Stdio,SSE,WebSocket,StreamableHTTP}` and their tests.
54
+ - `Robot#mcp_clients`, `Robot#mcp_client(name)`, `Robot#inject_mcp!`, the `mcp_servers:` keyword, `MCP::Server::VALID_TRANSPORT_TYPES`, `MCP::Server::DEFAULT_TIMEOUT`.
55
+
56
+ ### Fixed
57
+
58
+ - JSON-RPC error responses from a server are no longer silently ignored.
59
+ - Servers added after a robot's first run are now connected (the old client only initialized once).
60
+ - Remote servers no longer report `connected?` before the handshake completes.
61
+ - Template front matter in the flat `transport: stdio` form no longer fails silently.
62
+
63
+ ## [0.3.1] - 2026-11-01
64
+
11
65
  ### Changed
12
66
 
13
67
  - **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.
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>
@@ -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))