From 3c1d6cea62912ef471db0ceb78996b14ab72f50d Mon Sep 17 00:00:00 2001 From: Patrick Vice Date: Sun, 1 Mar 2026 08:17:07 -0500 Subject: [PATCH] feat: add first-class agent and toolset support --- docs/_config.yml | 1 + docs/_includes/head_custom.html | 7 + docs/_sass/custom/custom.scss | 83 +++++++++ docs/assets/js/copy-markdown.js | 266 +++++++++++++++++++++++++++++ docs/guides/index.md | 3 + lib/ruby_llm/mcp.rb | 50 +++++- lib/ruby_llm/mcp/agents.rb | 113 +++++++++++++ lib/ruby_llm/mcp/toolset.rb | 86 ++++++++++ spec/ruby_llm/mcp/agents_spec.rb | 270 ++++++++++++++++++++++++++++++ spec/ruby_llm/mcp/toolset_spec.rb | 58 +++++++ spec/ruby_llm/mcp_spec.rb | 63 +++++++ 11 files changed, 997 insertions(+), 3 deletions(-) create mode 100644 docs/assets/js/copy-markdown.js create mode 100644 lib/ruby_llm/mcp/agents.rb create mode 100644 lib/ruby_llm/mcp/toolset.rb create mode 100644 spec/ruby_llm/mcp/agents_spec.rb create mode 100644 spec/ruby_llm/mcp/toolset_spec.rb 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)