Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
strategy:
matrix:
ruby: ["3.1", "3.2", "3.3", "3.4", "4.0"]
# ruby: ["3.1", "3.2", "3.3", "3.4", "4.0", "jruby-10.0"]

steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ node_modules/
# AI specific files
CLAUDE.md
.claude
AGENTS.md
/AGENTS.md
94 changes: 94 additions & 0 deletions docs/guides/agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
layout: default
title: Agents
parent: Advanced
nav_order: 3
description: "Define reusable MCP toolsets and wire them into RubyLLM agents with lazy connection startup."
---

# Agents
{: .no_toc }

{: .label .label-yellow }
New in v1.1

This guide covers two integration points:

- `RubyLLM::MCP.toolset` for declaring named MCP tool groups.
- `RubyLLM::MCP::Agents` for attaching toolsets and MCP clients to `RubyLLM::Agent`.

## MCP DSL on Toolset

Use a named toolset to group MCP clients and apply include/exclude rules.

```ruby
# config/initializers/ruby_llm_mcp_toolsets.rb
RubyLLM::MCP.toolset(:support) do |toolset|
toolset.from_clients :filesystem, :projects
toolset.exclude_tools "delete_file"
end
```

That is the module-style config. You can also use hash-style DSL:

```ruby
RubyLLM::MCP.toolset(
:support,
clients: [:filesystem, :projects],
exclude_tools: ["delete_file"],
include_tools: ["read_file", "search_files"]
)
```

Use the block-scoped API when you want explicit access to the tools:

```ruby
RubyLLM::MCP.toolset(:support).with_tools do |support_tools|
RubyLLM.chat.with_tools(*support_tools).ask("Find unresolved support items.")
end
```

`with_tools` keeps the selected MCP connections alive while the block runs and closes them afterward.

## MCP::Agents module example

Use `RubyLLM::MCP::Agents` in your agent class to wire toolsets into `ask` without loading MCP before execution.

```ruby
class SupportAgent < RubyLLM::Agent
include RubyLLM::MCP::Agents

with_toolsets :support
end

SupportAgent.new.ask("Find unresolved items for files modified in the last day.")
```

This gives the agent lazy startup and automatic cleanup around each call.

You can also attach MCP clients directly:

```ruby
class SupportAgent < RubyLLM::Agent
include RubyLLM::MCP::Agents

with_mcps :filesystem, :projects
end
```

You can combine both:

```ruby
class SupportAgent < RubyLLM::Agent
include RubyLLM::MCP::Agents

with_toolsets :support
with_mcps :filesystem
end
```

Tool filtering should be configured on the toolset itself (via `include_tools` and `exclude_tools` in `RubyLLM::MCP.toolset`), not on the agent class.

`with_mcp_tools` remains available as an alias for `with_toolsets`.

Tools are attached only when `ask` runs, so MCP clients are started lazily and cleaned up right after the call.
3 changes: 3 additions & 0 deletions docs/guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ This section contains advanced implementation guidance.
## OAuth
- **[OAuth]({% link guides/oauth.md %})** {: .label .label-green } 1.0 - OAuth 2.1 support with PKCE and browser authentication

## Agent mode
- **[Agents]({% link guides/agents.md %})** {: .label .label-yellow } 1.1 - MCP toolset DSL and `RubyLLM::MCP::Agents` integration

## Upgrading

- **[Upgrading]({% link guides/upgrading.md %})** - Unified migration guide with sections for updates to 1.0, 0.8, and 0.7
167 changes: 152 additions & 15 deletions lib/ruby_llm/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ module RubyLLM
module MCP
module_function

TOOLSET_OPTION_MAPPINGS = {
from_clients: %i[clients client_names],
include_tools: %i[include_tools include],
exclude_tools: %i[exclude_tools exclude]
}.freeze
TOOLSET_OPTION_KEYS = TOOLSET_OPTION_MAPPINGS.values.flatten.freeze

def clients(config = RubyLLM::MCP.config.mcp_configuration)
if @clients.nil?
@clients = {}
Expand All @@ -43,41 +50,73 @@ def add_client(options)
def remove_client(name)
client = clients.delete(name)
client&.stop
connection_mutex.synchronize { connection_leases.delete(name.to_s) }
client
end

def client(...)
Client.new(...)
end

def establish_connection(&)
clients.each_value(&:start)
if block_given?
begin
yield clients
ensure
close_connection
end
else
clients
def establish_connection(client_names: nil)
selected_clients = select_clients(client_names)

unless block_given?
selected_clients.each_value(&:start)
return selected_clients
end

acquire_connections(selected_clients)
begin
yield selected_clients
ensure
release_connections(selected_clients)
end
end

def close_connection
clients.each_value do |client|
client.stop if client.alive?
connection_mutex.synchronize do
clients.each_value do |client|
client.stop if client.alive?
end
connection_leases.clear
end
end

def tools(blacklist: [], whitelist: [])
tools = @clients.values.map(&:tools)
.flatten
.reject { |tool| blacklist.include?(tool.name) }
tools = clients.values.map(&:tools)
.flatten
.reject { |tool| blacklist.include?(tool.name) }

tools = tools.select { |tool| whitelist.include?(tool.name) } if whitelist.any?
tools.uniq(&:name)
end

def toolset(name, options = nil)
if block_given? && !options.nil?
raise ArgumentError, "Provide either configuration options or a block, not both"
end

normalized_options = normalize_toolset_options(options) if options
toolset_name = name.to_sym
@toolsets ||= {}
configured_toolset = (@toolsets[toolset_name] ||= Toolset.new(name: toolset_name))

if block_given?
yield configured_toolset
return configured_toolset
end

return configured_toolset unless normalized_options

apply_toolset_options(configured_toolset, normalized_options)
end

def toolsets
configured_toolsets = @toolsets || {}
configured_toolsets.dup
end

def mcp_configurations
config.mcp_configuration.each_with_object({}) do |config, acc|
acc[config[:name]] = config
Expand All @@ -98,6 +137,104 @@ def config
def logger
config.logger
end

def apply_toolset_options(toolset, options)
TOOLSET_OPTION_MAPPINGS.each do |method_name, keys|
next unless keys.any? { |key| options.key?(key) }

values = keys.flat_map { |key| Array(options[key]) }
toolset.public_send(method_name, *values)
end

toolset
end
private_class_method :apply_toolset_options

def normalize_toolset_options(options)
normalized = options.dup.transform_keys(&:to_sym)
unknown_keys = normalized.keys - TOOLSET_OPTION_KEYS
return normalized if unknown_keys.empty?

label = unknown_keys.one? ? "option" : "options"
raise ArgumentError, "Unknown toolset #{label}: #{unknown_keys.join(', ')}"
end
private_class_method :normalize_toolset_options

def select_clients(client_names)
available_clients = clients.transform_keys(&:to_s)
return available_clients if client_names.nil?

requested_names = Array(client_names).flatten.compact.map(&:to_s).uniq
missing_names = requested_names - available_clients.keys
if missing_names.any?
raise Errors::ConfigurationError.new(
message: "Unknown MCP client name(s): #{missing_names.join(', ')}"
)
end

available_clients.slice(*requested_names)
end
private_class_method :select_clients

def acquire_connections(selected_clients)
connection_mutex.synchronize do
acquired_clients = []

begin
selected_clients.each do |name, client|
start_connection(name, client)
connection_leases[name] += 1
acquired_clients << [name, client]
end
rescue StandardError
release_connections_without_lock(acquired_clients.reverse)
raise
end
end
end
private_class_method :acquire_connections

def release_connections(selected_clients)
connection_mutex.synchronize do
release_connections_without_lock(selected_clients.to_a.reverse)
end
end
private_class_method :release_connections

def start_connection(name, client)
return unless connection_leases[name].zero?

client.start
rescue StandardError
client.stop if client.alive?
raise
end
private_class_method :start_connection

def release_connections_without_lock(selected_clients)
selected_clients.each do |name, client|
lease_count = connection_leases[name]
next if lease_count.zero?

if lease_count == 1
connection_leases.delete(name)
client.stop if client.alive?
else
connection_leases[name] = lease_count - 1
end
end
end
private_class_method :release_connections_without_lock

def connection_leases
@connection_leases ||= Hash.new(0)
end
private_class_method :connection_leases

def connection_mutex
@connection_mutex ||= Mutex.new
end
private_class_method :connection_mutex
end
end

Expand Down
Loading
Loading