robot_lab 0.3.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +28 -18
- data/lib/robot_lab/errors.rb +4 -0
- data/lib/robot_lab/hook_context.rb +9 -2
- data/lib/robot_lab/mcp/registry.rb +99 -0
- data/lib/robot_lab/mcp/server.rb +47 -83
- data/lib/robot_lab/mcp/server_discovery.rb +22 -9
- data/lib/robot_lab/mcp.rb +259 -0
- data/lib/robot_lab/robot/mcp_management.rb +159 -105
- data/lib/robot_lab/robot/tool_call_gating.rb +144 -0
- data/lib/robot_lab/robot.rb +72 -99
- data/lib/robot_lab/run_config.rb +3 -2
- data/lib/robot_lab/tool.rb +8 -23
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab.rb +1 -4
- metadata +14 -40
- data/lib/robot_lab/mcp/client.rb +0 -223
- data/lib/robot_lab/mcp/connection_poller.rb +0 -195
- data/lib/robot_lab/mcp/transports/base.rb +0 -64
- data/lib/robot_lab/mcp/transports/sse.rb +0 -120
- data/lib/robot_lab/mcp/transports/stdio.rb +0 -170
- data/lib/robot_lab/mcp/transports/streamable_http.rb +0 -141
- data/lib/robot_lab/mcp/transports/websocket.rb +0 -111
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65a808d1e3b2ba828752c2382fd3fc3f1193a66f5fb5d37226e0429747f5748e
|
|
4
|
+
data.tar.gz: b86e5d9c0e79d53ececcb0f0fe84e1e632579ebab8e49649989f825a8e7f0c72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f6cdec05c631f732d1806f70d6d3a3cb2eb37ee71e611a528508180e9c43b0900c8807e71c893854042f92e48b78f51c2652c2cdc8269b6f5055eed4c981986
|
|
7
|
+
data.tar.gz: f4e03bfce73ec1548797a7a4ca2ea7fa12516ec184dab78e78d030b21c728ccc5d051a54a768e6e250c18b4bfb13f35c204b09966b4714116a0b40f0f09952de
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
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
|
+
|
|
11
59
|
### Changed
|
|
12
60
|
|
|
13
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.
|
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> -
|
|
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
|
|
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
|
|
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) #
|
|
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
|
-
|
|
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
|
-
#
|
|
517
|
-
|
|
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: "
|
|
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
|
|
524
|
+
timeout: 30 # seconds; absent means ruby_llm's request_timeout
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
-
#
|
|
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: [
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
data/lib/robot_lab/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/robot_lab/mcp/server.rb
CHANGED
|
@@ -2,105 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
module RobotLab
|
|
4
4
|
module MCP
|
|
5
|
-
#
|
|
5
|
+
# The +RubyLLM::MCP+ subclass that {MCP.build} creates from a config Hash.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def transport_type
|
|
60
|
-
@transport[:type]
|
|
44
|
+
def initialize(context: nil, **inputs)
|
|
45
|
+
super
|
|
46
|
+
@connected = false
|
|
61
47
|
end
|
|
62
48
|
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
|
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
|
|
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::
|
|
87
|
+
# @param server [Hash, RubyLLM::MCP, Class<RubyLLM::MCP>]
|
|
88
88
|
# @return [String]
|
|
89
89
|
def self.topic_text(server)
|
|
90
|
-
|
|
91
|
-
"#{name} #{description_for(server)}".strip
|
|
90
|
+
"#{name_for(server)} #{description_for(server)}".strip
|
|
92
91
|
end
|
|
93
92
|
|
|
94
|
-
# @param server [Hash, MCP::
|
|
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 =
|
|
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
|
|
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))
|