diff --git a/docs/_config.yml b/docs/_config.yml
index e7f8146..fa3b56f 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -42,6 +42,7 @@ back_to_top_text: "Back to top"
# Footer content
footer_content: "Copyright © 2025 Patrick Vice. Distributed under an MIT license."
+markdown_source_base_url: "https://raw.githubusercontent.com/patvice/ruby_llm-mcp/main/docs"
# navigation
nav_enabled: true
diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html
index 0b0e0bf..c0722d6 100644
--- a/docs/_includes/head_custom.html
+++ b/docs/_includes/head_custom.html
@@ -88,3 +88,10 @@
}
})();
+
+
diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss
index 3196395..dd1daa2 100644
--- a/docs/_sass/custom/custom.scss
+++ b/docs/_sass/custom/custom.scss
@@ -254,3 +254,86 @@ html[data-rubyllm-theme="dark"] .logo-container .home-logo-dark {
font-size: 1.05rem;
}
}
+
+.main-content {
+ position: relative;
+}
+
+.page-actions {
+ position: absolute;
+ top: 0.2rem;
+ right: 0;
+ z-index: 2;
+}
+
+.page-copy-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.35rem 0.7rem;
+ border: 1px solid rgba(20, 20, 20, 0.15);
+ border-radius: 999px;
+ background: transparent;
+ color: rgba(20, 20, 20, 0.85);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1.1;
+ letter-spacing: 0.01em;
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
+}
+
+.page-copy-button:hover {
+ background: rgba(20, 20, 20, 0.06);
+ border-color: rgba(20, 20, 20, 0.25);
+}
+
+.page-copy-button:active {
+ transform: scale(0.97);
+}
+
+.page-copy-button:focus-visible {
+ outline: 2px solid rgba(20, 20, 20, 0.35);
+ outline-offset: 2px;
+}
+
+html[data-rubyllm-theme="dark"] .page-copy-button {
+ border-color: rgba(255, 255, 255, 0.25);
+ color: rgba(255, 255, 255, 0.9);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+html[data-rubyllm-theme="dark"] .page-copy-button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.35);
+}
+
+html[data-rubyllm-theme="dark"] .page-copy-button:focus-visible {
+ outline-color: rgba(255, 255, 255, 0.5);
+}
+
+@media (prefers-color-scheme: dark) {
+ html:not([data-rubyllm-theme="light"]) .page-copy-button {
+ border-color: rgba(255, 255, 255, 0.25);
+ color: rgba(255, 255, 255, 0.9);
+ background: rgba(255, 255, 255, 0.03);
+ }
+
+ html:not([data-rubyllm-theme="light"]) .page-copy-button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.35);
+ }
+
+ html:not([data-rubyllm-theme="light"]) .page-copy-button:focus-visible {
+ outline-color: rgba(255, 255, 255, 0.5);
+ }
+}
+
+@media (max-width: 50rem) {
+ .page-actions {
+ position: static;
+ margin-bottom: 0.75rem;
+ display: flex;
+ justify-content: flex-end;
+ }
+}
diff --git a/docs/assets/js/copy-markdown.js b/docs/assets/js/copy-markdown.js
new file mode 100644
index 0000000..67dad7a
--- /dev/null
+++ b/docs/assets/js/copy-markdown.js
@@ -0,0 +1,266 @@
+(function () {
+ function normalizeUrl(base, path) {
+ if (!base || !path) {
+ return null;
+ }
+ var trimmedBase = base.replace(/\/+$/, "");
+ var trimmedPath = path.replace(/^\/+/, "");
+ return trimmedBase + "/" + trimmedPath;
+ }
+
+ function defaultMarkdownBase() {
+ var repo = window.__rubyllmDocsRepoNwo || "";
+ var branch = window.__rubyllmDocsSourceBranch || "main";
+
+ if (!repo) {
+ return "";
+ }
+
+ return "https://raw.githubusercontent.com/" + repo + "/" + branch + "/docs";
+ }
+
+ function resolveMarkdownBase(button) {
+ var configuredBase = button.dataset.markdownBase || "";
+ if (configuredBase) {
+ return configuredBase;
+ }
+
+ var inferredBase = defaultMarkdownBase();
+ if (inferredBase) {
+ button.dataset.markdownBase = inferredBase;
+ }
+ return inferredBase;
+ }
+
+ function setButtonLabel(button, label) {
+ button.textContent = label;
+ }
+
+ function restoreLabelAfterDelay(button, label, delay) {
+ window.setTimeout(function () {
+ if (!button.dataset || button.dataset.isBusy === "true") {
+ return;
+ }
+ setButtonLabel(button, label);
+ }, delay);
+ }
+
+ function copyText(text) {
+ if (
+ window.navigator &&
+ window.navigator.clipboard &&
+ window.navigator.clipboard.writeText
+ ) {
+ return window.navigator.clipboard.writeText(text);
+ }
+
+ return new Promise(function (resolve, reject) {
+ var textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "fixed";
+ textarea.style.left = "-9999px";
+ document.body.appendChild(textarea);
+ textarea.select();
+
+ var ok = false;
+ try {
+ ok = document.execCommand("copy");
+ } catch (error) {
+ ok = false;
+ } finally {
+ document.body.removeChild(textarea);
+ }
+
+ if (ok) {
+ resolve();
+ } else {
+ reject(new Error("Unable to copy."));
+ }
+ });
+ }
+
+ function getVisiblePageText() {
+ var main = document.querySelector("#main-content > main");
+ if (!main) {
+ return "";
+ }
+
+ var clone = main.cloneNode(true);
+ var selectorsToRemove = [
+ ".page-actions",
+ ".anchor-heading",
+ "#markdown-toc",
+ "#table-of-contents",
+ "script",
+ "style"
+ ];
+
+ selectorsToRemove.forEach(function (selector) {
+ clone.querySelectorAll(selector).forEach(function (node) {
+ node.remove();
+ });
+ });
+
+ return (clone.textContent || "")
+ .replace(/\u00a0/g, " ")
+ .replace(/[ \t]+\n/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+ }
+
+ function fetchMarkdown(sourceUrl) {
+ if (!sourceUrl) {
+ return Promise.reject(new Error("Missing markdown source URL."));
+ }
+
+ return window.fetch(sourceUrl, { cache: "no-store" })
+ .then(function (response) {
+ if (!response.ok) {
+ throw new Error("Failed to fetch markdown source.");
+ }
+ return response.text();
+ })
+ .then(function (markdown) {
+ return stripFrontMatter(markdown);
+ });
+ }
+
+ function stripFrontMatter(markdown) {
+ var lines = markdown.split("\n");
+ if (lines.length < 3 || lines[0].trim() !== "---") {
+ return markdown;
+ }
+
+ var endIndex = -1;
+ for (var i = 1; i < lines.length; i += 1) {
+ if (lines[i].trim() === "---") {
+ endIndex = i;
+ break;
+ }
+ }
+
+ if (endIndex === -1) {
+ return markdown;
+ }
+
+ return lines.slice(endIndex + 1).join("\n").replace(/^\n+/, "");
+ }
+
+ function setupButton(button) {
+ var base = resolveMarkdownBase(button);
+ var path = button.dataset.markdownPath;
+ var defaultLabel = button.dataset.labelDefault || "Copy page";
+ var successLabel = button.dataset.labelSuccess || "Copied";
+ var errorLabel = button.dataset.labelError || "Copy failed";
+
+ setButtonLabel(button, defaultLabel);
+
+ var sourceUrl = normalizeUrl(base, path);
+ if (sourceUrl) {
+ window.fetch(sourceUrl, { cache: "no-store" })
+ .then(function (response) {
+ if (!response.ok) {
+ throw new Error("Failed to fetch markdown source.");
+ }
+ return response.text();
+ })
+ .then(function (markdown) {
+ button.dataset.markdownSource = stripFrontMatter(markdown);
+ })
+ .catch(function () {
+ button.dataset.markdownSource = "";
+ });
+ } else {
+ button.dataset.markdownSource = "";
+ button.title = "Missing markdown source configuration.";
+ }
+
+ button.addEventListener("click", function () {
+ if (button.dataset.isBusy === "true") {
+ return;
+ }
+
+ button.dataset.isBusy = "true";
+ button.disabled = true;
+ setButtonLabel(button, "Copying...");
+
+ var cachedMarkdown = button.dataset.markdownSource || "";
+ var copyPromise = cachedMarkdown
+ ? copyText(cachedMarkdown)
+ : fetchMarkdown(sourceUrl)
+ .then(function (strippedMarkdown) {
+ button.dataset.markdownSource = strippedMarkdown;
+ return copyText(strippedMarkdown);
+ })
+ .catch(function () {
+ var visibleText = getVisiblePageText();
+ if (!visibleText) {
+ throw new Error("Unable to load copy content.");
+ }
+ return copyText(visibleText);
+ });
+
+ copyPromise
+ .then(function () {
+ setButtonLabel(button, successLabel);
+ button.title = "Copied to clipboard.";
+ button.dataset.isBusy = "false";
+ button.disabled = false;
+ restoreLabelAfterDelay(button, defaultLabel, 2000);
+ })
+ .catch(function () {
+ setButtonLabel(button, errorLabel);
+ button.title = "Unable to copy markdown.";
+ button.dataset.isBusy = "false";
+ button.disabled = false;
+ restoreLabelAfterDelay(button, defaultLabel, 2500);
+ });
+ });
+ }
+
+ function createButtonIfMissing() {
+ if (document.querySelector(".js-copy-page-markdown")) {
+ return;
+ }
+
+ var markdownPath = window.__rubyllmMcpMarkdownPath || "";
+ if (!markdownPath || markdownPath.indexOf(".md") === -1) {
+ return;
+ }
+
+ var main = document.querySelector("#main-content > main");
+ if (!main) {
+ return;
+ }
+
+ var actions = document.createElement("div");
+ actions.className = "page-actions";
+
+ var button = document.createElement("button");
+ button.type = "button";
+ button.className = "page-copy-button js-copy-page-markdown";
+ button.dataset.markdownBase = window.__rubyllmMcpMarkdownSourceBaseUrl || "";
+ button.dataset.markdownPath = markdownPath;
+ button.dataset.labelDefault = "📋 Copy page";
+ button.dataset.labelSuccess = "✅ Copied!";
+ button.dataset.labelError = "âš Copy failed";
+ button.innerHTML = 'Copy page';
+
+ actions.appendChild(button);
+ main.insertBefore(actions, main.firstElementChild);
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ createButtonIfMissing();
+
+ var buttons = document.querySelectorAll(".js-copy-page-markdown");
+ if (!buttons.length) {
+ return;
+ }
+
+ buttons.forEach(function (button) {
+ setupButton(button);
+ });
+ });
+})();
diff --git a/docs/guides/index.md b/docs/guides/index.md
index 79dd345..b5ed33b 100644
--- a/docs/guides/index.md
+++ b/docs/guides/index.md
@@ -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 %})** - 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
diff --git a/lib/ruby_llm/mcp.rb b/lib/ruby_llm/mcp.rb
index 24ed1ca..1856f4b 100644
--- a/lib/ruby_llm/mcp.rb
+++ b/lib/ruby_llm/mcp.rb
@@ -26,6 +26,12 @@ 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
+
def clients(config = RubyLLM::MCP.config.mcp_configuration)
if @clients.nil?
@clients = {}
@@ -70,14 +76,38 @@ def close_connection
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)
+ toolset_name = name.to_sym
+ @toolsets ||= {}
+ configured_toolset = (@toolsets[toolset_name] ||= Toolset.new(name: toolset_name))
+
+ if block_given?
+ unless options.nil?
+ raise ArgumentError, "Provide either configuration options or a block, not both"
+ end
+
+ yield configured_toolset
+ return configured_toolset
+ end
+
+ return configured_toolset unless options
+
+ apply_toolset_options(configured_toolset, 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
@@ -98,6 +128,20 @@ def config
def logger
config.logger
end
+
+ def apply_toolset_options(toolset, options)
+ config = options.dup.transform_keys(&:to_sym)
+
+ TOOLSET_OPTION_MAPPINGS.each do |method_name, keys|
+ next unless keys.any? { |key| config[key] }
+
+ values = keys.flat_map { |key| Array(config[key]) }
+ toolset.public_send(method_name, *values)
+ end
+
+ toolset
+ end
+ private_class_method :apply_toolset_options
end
end
diff --git a/lib/ruby_llm/mcp/agents.rb b/lib/ruby_llm/mcp/agents.rb
new file mode 100644
index 0000000..fcf4119
--- /dev/null
+++ b/lib/ruby_llm/mcp/agents.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module MCP
+ module Agents
+ def self.included(base)
+ base.extend(ClassMethods)
+ base.prepend(InstanceMethods)
+ end
+
+ module ClassMethods
+ def with_toolsets(*toolset_names)
+ @mcp_toolset_names = toolset_names.flatten.compact.map(&:to_s).uniq
+ self
+ end
+
+ alias with_mcp_tools with_toolsets
+
+ def with_mcps(*mcp_names)
+ @mcp_client_names = mcp_names.flatten.compact.map(&:to_s).uniq
+ self
+ end
+
+ def mcp_toolset_names
+ return @mcp_toolset_names if instance_variable_defined?(:@mcp_toolset_names)
+ return superclass.mcp_toolset_names if superclass.respond_to?(:mcp_toolset_names)
+
+ []
+ end
+
+ def mcp_client_names
+ return @mcp_client_names if instance_variable_defined?(:@mcp_client_names)
+ return superclass.mcp_client_names if superclass.respond_to?(:mcp_client_names)
+
+ []
+ end
+
+ def mcp_tools_from_clients(clients)
+ return [] if mcp_toolset_names.empty? && mcp_client_names.empty?
+
+ normalized_clients = normalize_clients(clients)
+
+ toolset_tools = resolve_toolset_tools(normalized_clients)
+ mcp_tools = resolve_mcp_tools(normalized_clients)
+
+ (toolset_tools + mcp_tools).uniq(&:name)
+ end
+
+ def with_mcp_tools?
+ mcp_toolset_names.any? || mcp_client_names.any?
+ end
+
+ private
+
+ def normalize_clients(clients)
+ return clients.transform_keys(&:to_s) if clients.is_a?(Hash)
+
+ Array(clients).each_with_object({}) do |client, acc|
+ acc[client.name.to_s] = client
+ end
+ end
+
+ def resolve_toolset_tools(clients)
+ return [] if mcp_toolset_names.empty?
+
+ configured_toolsets = RubyLLM::MCP.toolsets
+ missing_toolsets = mcp_toolset_names.reject { |name| configured_toolsets.key?(name.to_sym) }
+ if missing_toolsets.any?
+ raise Errors::ConfigurationError.new(
+ message: "Unknown MCP toolset name(s): #{missing_toolsets.join(', ')}"
+ )
+ end
+
+ mcp_toolset_names.flat_map do |name|
+ toolset = configured_toolsets.fetch(name.to_sym)
+ toolset.tools(clients: clients.values)
+ end
+ end
+
+ def resolve_mcp_tools(clients)
+ return [] if mcp_client_names.empty?
+
+ missing_clients = mcp_client_names - clients.keys
+ if missing_clients.any?
+ raise Errors::ConfigurationError.new(
+ message: "Unknown MCP client name(s): #{missing_clients.join(', ')}"
+ )
+ end
+
+ mcp_client_names.flat_map { |name| clients.fetch(name).tools }
+ end
+ end
+
+ module InstanceMethods
+ def ask(...)
+ return with_mcp_tools_connection { super } if self.class.with_mcp_tools?
+
+ super
+ end
+
+ private
+
+ def with_mcp_tools_connection
+ RubyLLM::MCP.establish_connection do |clients|
+ tools = self.class.mcp_tools_from_clients(clients)
+ chat.with_tools(*tools) if tools.any?
+ yield
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ruby_llm/mcp/toolset.rb b/lib/ruby_llm/mcp/toolset.rb
new file mode 100644
index 0000000..f6e2ced
--- /dev/null
+++ b/lib/ruby_llm/mcp/toolset.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module MCP
+ class Toolset
+ attr_reader :name
+
+ def initialize(name:)
+ @name = name.to_sym
+ @client_names = []
+ @include_tool_names = []
+ @exclude_tool_names = []
+ @exclusive = false
+ end
+
+ def from_clients(*names)
+ @client_names = normalize_names(names)
+ self
+ end
+
+ alias clients from_clients
+
+ def include_tools(*names)
+ @include_tool_names = normalize_names(names)
+ @exclusive = true
+ self
+ end
+
+ def exclude_tools(*names)
+ @exclude_tool_names = normalize_names(names)
+ self
+ end
+
+ def tools(clients:)
+ normalized_clients = clients.is_a?(Hash) ? clients.values : clients
+
+ return [] if normalized_clients.empty?
+
+ selected_clients = if @client_names.any?
+ clients_by_name = normalized_clients.each_with_object({}) do |client, acc|
+ acc[client.name.to_s] = client
+ end
+ missing_names = @client_names - clients_by_name.keys
+ if missing_names.any?
+ raise Errors::ConfigurationError.new(
+ message: "Unknown MCP client name(s): #{missing_names.join(', ')}"
+ )
+ end
+
+ clients_by_name.values_at(*@client_names)
+ else
+ normalized_clients
+ end
+
+ resolved_tools = selected_clients.map(&:tools).flatten
+ resolved_tools = resolve_include_tools(resolved_tools)
+ resolved_tools = resolve_exclude_tools(resolved_tools)
+ resolved_tools.uniq(&:name)
+ end
+
+ def to_a
+ RubyLLM::MCP.establish_connection do |clients_map|
+ tools(clients: clients_map.values)
+ end
+ end
+
+ private
+
+ def resolve_include_tools(resolved_tools)
+ return resolved_tools unless @exclusive && @include_tool_names.any?
+
+ resolved_tools.select { |tool| @include_tool_names.include?(tool.name) }
+ end
+
+ def resolve_exclude_tools(resolved_tools)
+ return resolved_tools if @exclude_tool_names.empty?
+
+ resolved_tools.reject { |tool| @exclude_tool_names.include?(tool.name) }
+ end
+
+ def normalize_names(names)
+ names.flatten.compact.map(&:to_s)
+ end
+ end
+ end
+end
diff --git a/spec/ruby_llm/mcp/agents_spec.rb b/spec/ruby_llm/mcp/agents_spec.rb
new file mode 100644
index 0000000..a8189db
--- /dev/null
+++ b/spec/ruby_llm/mcp/agents_spec.rb
@@ -0,0 +1,270 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe RubyLLM::MCP::Agents do
+ def configure_agent_e2e_mcp!
+ RubyLLM::MCP.instance_variable_set(:@clients, nil)
+ RubyLLM::MCP.instance_variable_set(:@toolsets, nil)
+
+ RubyLLM::MCP.configure do |config|
+ config.mcp_configuration = [
+ {
+ name: "agent_stdio",
+ adapter: :ruby_llm,
+ transport_type: :stdio,
+ start: false,
+ request_timeout: 10_000,
+ config: {
+ command: "bun",
+ args: ["spec/fixtures/typescript-mcp/index.ts", "--stdio"],
+ env: { "TEST_ENV" => "this_is_a_test" }
+ }
+ }
+ ]
+ end
+ end
+
+ def cleanup_agent_e2e_mcp!
+ RubyLLM::MCP.close_connection
+ RubyLLM::MCP.instance_variable_set(:@clients, nil)
+ RubyLLM::MCP.instance_variable_set(:@toolsets, nil)
+ end
+
+ before do
+ RubyLLM::MCP.instance_variable_set(:@toolsets, nil)
+ end
+
+ after do
+ RubyLLM::MCP.instance_variable_set(:@toolsets, nil)
+ end
+
+ describe "toolset resolution" do
+ let(:clients) do
+ {
+ "filesystem" => double(
+ "FilesystemClient",
+ name: "filesystem",
+ tools: [double("Tool", name: "read_file"), double("Tool", name: "delete_file")]
+ ),
+ "projects" => double("ProjectsClient", name: "projects", tools: [double("Tool", name: "list_projects")])
+ }
+ end
+
+ it "fails closed when an unknown toolset name is configured" do
+ klass = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcp_tools :typoed_toolset
+ end
+
+ expect do
+ klass.mcp_tools_from_clients(clients)
+ end.to raise_error(
+ RubyLLM::MCP::Errors::ConfigurationError,
+ /Unknown MCP toolset name\(s\): typoed_toolset/
+ )
+
+ expect(RubyLLM::MCP.toolsets).to eq({})
+ end
+
+ it "resolves tools from configured toolsets" do
+ RubyLLM::MCP.toolset(
+ :support,
+ clients: [:filesystem],
+ include_tools: ["read_file"]
+ )
+
+ klass = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcp_tools :support
+ end
+
+ tools = klass.mcp_tools_from_clients(clients)
+
+ expect(tools.map(&:name)).to eq(["read_file"])
+ end
+
+ it "resolves tools directly from configured MCP clients" do
+ klass = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcps :filesystem
+ end
+
+ tools = klass.mcp_tools_from_clients(clients)
+
+ expect(tools.map(&:name)).to contain_exactly("read_file", "delete_file")
+ end
+
+ it "supports combining toolsets and mcp clients" do
+ RubyLLM::MCP.toolset(
+ :support,
+ clients: [:projects],
+ include_tools: ["list_projects"]
+ )
+
+ klass = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_toolsets :support
+ with_mcps :filesystem
+ end
+
+ tools = klass.mcp_tools_from_clients(clients)
+
+ expect(tools.map(&:name)).to contain_exactly("list_projects", "read_file", "delete_file")
+ end
+
+ it "fails closed when an unknown mcp client name is configured" do
+ klass = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcps :unknown_mcp
+ end
+
+ expect do
+ klass.mcp_tools_from_clients(clients)
+ end.to raise_error(
+ RubyLLM::MCP::Errors::ConfigurationError,
+ /Unknown MCP client name\(s\): unknown_mcp/
+ )
+ end
+
+ it "keeps with_mcp_tools as an alias for with_toolsets" do
+ klass = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcp_tools :support
+ end
+
+ expect(klass.mcp_toolset_names).to eq(["support"])
+ end
+
+ it "accepts toolsets as varargs or arrays" do
+ varargs_class = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_toolsets :support, :apples
+ end
+ array_class = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_toolsets %i[support apples]
+ end
+
+ expect(varargs_class.mcp_toolset_names).to eq(%w[support apples])
+ expect(array_class.mcp_toolset_names).to eq(%w[support apples])
+ end
+
+ it "accepts mcps as varargs or arrays" do
+ varargs_class = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcps :filesystem, :projects
+ end
+ array_class = Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcps %i[filesystem projects]
+ end
+
+ expect(varargs_class.mcp_client_names).to eq(%w[filesystem projects])
+ expect(array_class.mcp_client_names).to eq(%w[filesystem projects])
+ end
+ end
+
+ describe "class-level DSL inheritance" do
+ let(:base_class) do
+ Class.new do
+ include RubyLLM::MCP::Agents
+
+ with_mcp_tools :support
+ with_mcps :projects
+ end
+ end
+
+ it "inherits toolset and mcp config in subclasses" do
+ child_class = Class.new(base_class)
+
+ expect(child_class.mcp_toolset_names).to eq(["support"])
+ expect(child_class.mcp_client_names).to eq(["projects"])
+ end
+
+ it "allows subclasses to override inherited config" do
+ child_class = Class.new(base_class) do
+ with_mcp_tools :security
+ with_mcps :filesystem
+ end
+
+ expect(child_class.mcp_toolset_names).to eq(["security"])
+ expect(child_class.mcp_client_names).to eq(["filesystem"])
+ end
+
+ it "does not expose include_tools/exclude_tools in the agents DSL" do
+ klass = Class.new do
+ include RubyLLM::MCP::Agents
+ end
+
+ expect(klass).not_to respond_to(:include_tools)
+ expect(klass).not_to respond_to(:exclude_tools)
+ end
+ end
+
+ describe "end-to-end agent + toolset + llm" do
+ before do
+ MCPTestConfiguration.reset_config!
+ MCPTestConfiguration.configure_ruby_llm!
+ configure_agent_e2e_mcp!
+ end
+
+ after do
+ cleanup_agent_e2e_mcp!
+ end
+
+ it "runs with_toolsets and calls MCP tool(s) during agent ask" do
+ RubyLLM::MCP.toolset(
+ :agent_messages,
+ clients: [:agent_stdio],
+ include_tools: ["list_messages"]
+ )
+
+ klass = Class.new(RubyLLM::Agent) do
+ include RubyLLM::MCP::Agents
+
+ model "gpt-4.1"
+ with_toolsets :agent_messages
+ end
+
+ response = nil
+ VCR.use_cassette(
+ "with_stdio-native_with_openai_gpt-4_1_with_tool_adds_a_tool_to_the_chat",
+ allow_playback_repeats: true
+ ) do
+ response = klass.new.ask("Can you pull messages for ruby channel and let me know what they say?")
+ end
+
+ expect(response.content).to include("Ruby is a great language")
+ end
+
+ it "runs with_mcps and calls MCP tool(s) during agent ask" do
+ klass = Class.new(RubyLLM::Agent) do
+ include RubyLLM::MCP::Agents
+
+ model "gpt-4.1"
+ with_mcps :agent_stdio
+ end
+
+ response = nil
+ VCR.use_cassette(
+ "with_stdio-native_with_openai_gpt-4_1_with_tools_adds_tools_to_the_chat",
+ allow_playback_repeats: true
+ ) do
+ response = klass.new.ask("Can you add 1 and 2?")
+ end
+
+ expect(response.content).to include("3")
+ end
+ end
+end
diff --git a/spec/ruby_llm/mcp/toolset_spec.rb b/spec/ruby_llm/mcp/toolset_spec.rb
new file mode 100644
index 0000000..0f1399e
--- /dev/null
+++ b/spec/ruby_llm/mcp/toolset_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe RubyLLM::MCP::Toolset do
+ let(:read_file) { instance_double(RubyLLM::MCP::Tool, name: "read_file") }
+ let(:delete_file) { instance_double(RubyLLM::MCP::Tool, name: "delete_file") }
+ let(:list_projects) { instance_double(RubyLLM::MCP::Tool, name: "list_projects") }
+ let(:duplicate_read_file) { instance_double(RubyLLM::MCP::Tool, name: "read_file") }
+ let(:filesystem_client) do
+ instance_double(RubyLLM::MCP::Client, name: "filesystem", tools: [read_file, delete_file])
+ end
+ let(:projects_client) do
+ instance_double(RubyLLM::MCP::Client, name: "projects", tools: [list_projects, duplicate_read_file])
+ end
+ let(:clients_map) do
+ {
+ "filesystem" => filesystem_client,
+ "projects" => projects_client
+ }
+ end
+
+ describe "#tools" do
+ it "filters to configured client names" do
+ toolset = described_class.new(name: :support).from_clients("filesystem")
+
+ tool_names = toolset.tools(clients: clients_map).map(&:name)
+ expect(tool_names).to contain_exactly("read_file", "delete_file")
+ end
+
+ it "raises when a configured client name is missing" do
+ toolset = described_class.new(name: :support).from_clients("missing_client")
+
+ expect do
+ toolset.tools(clients: clients_map)
+ end.to raise_error(
+ RubyLLM::MCP::Errors::ConfigurationError,
+ /Unknown MCP client name\(s\): missing_client/
+ )
+ end
+
+ it "supports include and exclude filters together" do
+ toolset = described_class.new(name: :support)
+ .include_tools("read_file", "list_projects")
+ .exclude_tools("list_projects")
+
+ tool_names = toolset.tools(clients: clients_map).map(&:name)
+ expect(tool_names).to eq(["read_file"])
+ end
+
+ it "deduplicates tools by name across clients" do
+ toolset = described_class.new(name: :support)
+
+ tool_names = toolset.tools(clients: clients_map).map(&:name)
+ expect(tool_names).to contain_exactly("read_file", "delete_file", "list_projects")
+ end
+ end
+end
diff --git a/spec/ruby_llm/mcp_spec.rb b/spec/ruby_llm/mcp_spec.rb
index 0242663..4fdd9c3 100644
--- a/spec/ruby_llm/mcp_spec.rb
+++ b/spec/ruby_llm/mcp_spec.rb
@@ -260,6 +260,69 @@
end
end
+ describe "#toolset" do
+ let(:read_file) { instance_double(RubyLLM::MCP::Tool, name: "read_file") }
+ let(:delete_file) { instance_double(RubyLLM::MCP::Tool, name: "delete_file") }
+ let(:list_projects) { instance_double(RubyLLM::MCP::Tool, name: "list_projects") }
+ let(:filesystem_client) do
+ instance_double(RubyLLM::MCP::Client, name: "filesystem", tools: [read_file, delete_file])
+ end
+ let(:projects_client) { instance_double(RubyLLM::MCP::Client, name: "projects", tools: [list_projects]) }
+ let(:clients) do
+ {
+ "filesystem" => filesystem_client,
+ "projects" => projects_client
+ }
+ end
+
+ before do
+ RubyLLM::MCP.instance_variable_set(:@toolsets, nil)
+ end
+
+ after do
+ RubyLLM::MCP.instance_variable_set(:@toolsets, nil)
+ end
+
+ it "raises when both options and a block are provided" do
+ expect do
+ RubyLLM::MCP.toolset(:support, clients: [:filesystem]) do |toolset|
+ toolset.include_tools("read_file")
+ end
+ end.to raise_error(ArgumentError, /Provide either configuration options or a block, not both/)
+ end
+
+ it "supports string keys and alias option names" do
+ toolset = RubyLLM::MCP.toolset(
+ "support",
+ {
+ "clients" => ["filesystem"],
+ "client_names" => ["projects"],
+ "include_tools" => ["read_file"],
+ "include" => ["list_projects"],
+ "exclude_tools" => ["delete_file"],
+ "exclude" => ["list_projects"]
+ }
+ )
+
+ tool_names = toolset.tools(clients: clients).map(&:name)
+ expect(tool_names).to eq(["read_file"])
+ end
+
+ it "resets filters when aliases are passed empty arrays" do
+ RubyLLM::MCP.toolset(
+ :support,
+ clients: ["filesystem"],
+ include_tools: ["read_file"],
+ exclude_tools: ["delete_file"]
+ )
+
+ RubyLLM::MCP.toolset(:support, clients: [], include: [], exclude: [])
+
+ tool_names = RubyLLM::MCP.toolset(:support).tools(clients: clients).map(&:name)
+ expect(tool_names).to contain_exactly("read_file", "delete_file", "list_projects")
+ end
+ end
+
describe "#configure" do
it "yields the configuration object" do
config = instance_double(RubyLLM::MCP::Configuration)