class RubyLLM::MCP
An MCP is a client for a Model Context Protocol server. Describe the server to connect to in a subclass, the way you describe a Tool or an Agent, then hand an instance to a chat:
class Linear < RubyLLM::MCP url "https://mcp.linear.app/mcp" inputs :user bearer_token { user.linear_token } end linear = Linear.new(user: current_user) linear.tools # => [#<RubyLLM::MCP::Tool name: "list_issues", ...>, ...] linear.list_issues(query: "bug") # => #<RubyLLM::MCP::Result ...>
A url connects over Streamable HTTP; a command starts a local server that speaks over stdio:
class Files < RubyLLM::MCP command "npx", "-y", "@modelcontextprotocol/server-filesystem", "." end
A transport carries the messages any other way, such as through a tunnel to a server behind a firewall.
Settings that depend on runtime state take a block or a method name, evaluated on the instance, so declared ::inputs and private methods are available. RubyLLM.mcp builds one inline when a class is not worth writing.
Public Class Methods
# File lib/ruby_llm/mcp.rb, line 368 def after_change(method = nil, &block) add_callback(:after_change, method, block) end
Registers a callback for the changes the server announces. Pass a method name or a block; either runs on the MCP instance with what changed: :tools, :prompts, or :resources when the server’s list of them changes, the MCP::Resource whose content changed, or the MCP::Task whose status changed. RubyLLM has already forgotten the old tools when :tools arrives.
after_change :refresh after_change { |change| Rails.cache.delete("handbook/tools") if change == :tools }
Servers that predate 2026-07-28 announce changes as they answer a request; the callback runs once the request is answered. Newer servers announce them only while the MCP listens; see listen. Changes made while a listener reconnects are lost, so once it is back, the callback runs for everything it listens to.
# File lib/ruby_llm/mcp.rb, line 349 def after_progress(method = nil, &block) add_callback(:after_progress, method, block) end
Registers a callback for the progress the server reports while it works on a request. Pass a method name or a block; either runs on the MCP instance with a Progress. In a chat, the progress of a tool call also reaches Chat#after_tool_progress.
after_progress :broadcast_progress after_progress { |progress| puts progress.message }
# File lib/ruby_llm/mcp.rb, line 159 def bearer_token(value = nil, &block) return @bearer_token if value.nil? && block.nil? @bearer_token = block || value end
Sets the bearer token sent in the Authorization header. Pass the token, a method name, or a block. Called with no argument, returns the configured value.
bearer_token ENV.fetch("LINEAR_API_KEY") bearer_token { user.linear_token }
# File lib/ruby_llm/mcp.rb, line 399 def before_input_request(method = nil, &block) add_callback(:before_input_request, method, block) end
Registers a callback for the server’s requests for input from the user. Pass a method name or a block; either runs on the MCP instance with an MCP::InputRequest to answer or decline. In a chat, a request no callback answers pauses the tool call; see Chat#pending_inputs.
before_input_request :ask_operator before_input_request { |request| request.answer(environment: "staging") }
Source
# File lib/ruby_llm/mcp.rb, line 86 def command(*argv) return @command if argv.empty? @command = argv.flatten end
Sets the command that starts a local server speaking over stdio. The process starts on the first request. Called with no arguments, returns the configured command.
command "npx", "-y", "@modelcontextprotocol/server-filesystem", "."
Source
# File lib/ruby_llm/mcp.rb, line 333 def defer(*names) @deferrals = deferrals + [names.flatten.map(&:to_s)] end
Keeps the named server tools out of the model’s context until the provider’s tool search loads them. Without names, every tool of the server is deferred. Chat#with_mcp with defer: overrides it per chat.
defer :search_code, :list_workflows defer
Source
# File lib/ruby_llm/mcp.rb, line 121 def directory(value = nil) return @directory if value.nil? @directory = value end
Sets the working directory for a stdio server’s process.
directory Rails.root
Source
# File lib/ruby_llm/mcp.rb, line 132 def env(**variables) return @env || {} if variables.empty? @env = env.merge(variables) end
Adds environment variables for a stdio server’s process. Values may be blocks or method names. Called with no arguments, returns them.
env NODE_ENV: "production", API_KEY: -> { user.api_key }
Source
# File lib/ruby_llm/mcp.rb, line 256 def except(*names) return @except || [] if names.empty? @except = names.flatten.map(&:to_s) end
Hides the named server tools from the model.
except :delete_repository
Source
# File lib/ruby_llm/mcp.rb, line 423 def extension(name, **settings) name, defaults = extension_identifier(name) @extensions = extensions.merge(name => defaults.merge(settings.transform_keys(&:to_s))) end
Declares an extension to the protocol that your app supports, so servers can use it. settings belong to the extension and go to the server as written. Extensions are named with a vendor prefix.
extension "com.example/audit", level: "full"
RubyLLM implements two extensions you declare by name. With :apps, MCP Apps, tools come with a UI your app renders next to their results, and tools that only a UI may call stay out of chats; see MCP::Tool#visibility. With :tasks, a server may run a long tool call in the background; see MCP::Task.
extension :apps extension :tasks
RubyLLM declares extensions in the capabilities of every request, and when it connects to a server that predates 2026-07-28.
Raises ArgumentError for a name without a vendor prefix, or a Symbol RubyLLM does not know.
# File lib/ruby_llm/mcp.rb, line 144 def header(name, value = nil, &block) @headers = headers.merge(name.to_s => block || value) end
Adds an HTTP header sent with every request to the server. Pass the value, a method name, or a block.
header "X-MCP-Toolsets", "issues,pull_requests" header("X-Account") { user.account_id }
Source
# File lib/ruby_llm/mcp.rb, line 381 def input_requests(*kinds) return @input_requests || INPUT_REQUESTS if kinds.empty? kinds = (kinds.flatten - [false, nil]).map(&:to_sym) unknown = kinds - INPUT_REQUESTS raise ArgumentError, "Unknown input requests: #{unknown.join(', ')}" if unknown.any? @input_requests = kinds end
Sets the kinds of requests for input the server may send: :form, :url, or both, the default. Pass false when your app cannot show them to anyone, so a call never waits on an answer that will not come. RubyLLM declines requests of other kinds. Called with no arguments, returns the accepted kinds.
input_requests false input_requests :url
Source
# File lib/ruby_llm/mcp.rb, line 235 def inputs(*names) return @input_names || [] if names.empty? @input_names = names.flatten.map(&:to_sym) @input_names.each { |input| define_method(input) { @inputs[input] } } end
Declares named inputs. Instances take them as keywords and read them as methods, so blocks such as a bearer_token can use them. Called with no arguments, returns the declared names.
inputs :user
Source
# File lib/ruby_llm/mcp.rb, line 442 def log_level(level = nil) return @log_level if level.nil? raise ArgumentError, "Unknown MCP log level: #{level}" unless LOG_LEVELS.key?(level.to_sym) @log_level = level.to_sym end
Asks the server for the log messages it writes while it works on a request, at level and above, and writes them to the RubyLLM logger. The levels are the protocol’s, from :debug through :info, :notice, :warning, :error, :critical, and :alert to :emergency. Without a level, servers send no log messages. Called with no argument, returns the level.
log_level :warning
Raises ArgumentError for a level the protocol does not define.
Source
# File lib/ruby_llm/mcp.rb, line 512 def initialize(context: nil, **inputs) unknown = inputs.keys - self.class.inputs raise ArgumentError, "Unknown MCP inputs: #{unknown.join(', ')}" if unknown.any? @context = context @inputs = inputs end
Creates an MCP. Keywords are the values of the declared ::inputs. Pass a Context as context: to connect with its configuration: its faraday_adapter, http_proxy, and request_timeout apply to the server’s requests and to its OAuth requests.
Linear.new(user: current_user) Linear.new(user: current_user, context: RubyLLM.context { |config| config.http_proxy = proxy })
Raises ArgumentError for keywords that are not declared inputs.
# File lib/ruby_llm/mcp.rb, line 207 def oauth(owner: nil, scopes: nil, client_id: nil, client_secret: nil, grant: nil, private_key: nil, assertion: nil, identity_provider: nil) raise ArgumentError, "Unknown OAuth grant: #{grant}" unless grant.nil? || OAuth::GRANTS.include?(grant) @oauth = { owner:, scopes:, client_id:, client_secret:, grant:, private_key:, assertion:, identity_provider: } end
Authorizes requests with OAuth, as the MCP authorization spec describes. RubyLLM discovers the server’s authorization server and registers itself unless you pass the client_id: and client_secret: of an app you registered, which servers such as Slack require. Those only go to the authorization server they were first used with. owner: names whose credentials these are, usually an input. scopes: overrides the scopes the server asks for.
oauth owner: :user oauth owner: :user, client_id: ENV["SLACK_CLIENT_ID"], client_secret: ENV["SLACK_CLIENT_SECRET"]
Send the user to MCP#authorization_url, then pass the callback’s parameters to MCP#authorize. Servers that require DPoP get tokens bound to a key that RubyLLM keeps with the credentials.
+grant: :client_credentials+ connects your app as itself, with no user: RubyLLM requests a token when the server first asks for one and a new one before it expires. private_key:, a PEM string or an OpenSSL key, signs a short-lived assertion in place of client_secret:, for any app you registered.
oauth grant: :client_credentials, client_id: "reports", private_key: ENV["REPORTS_PRIVATE_KEY"]
assertion: presents a JWT your platform issued, such as a Kubernetes service account token, with the JWT bearer grant (+grant: :jwt_bearer+), so a workload needs no credentials of its own. A block or method name is read for every token, as platforms rotate them.
oauth assertion: -> { File.read("/var/run/secrets/tokens/mcp-token") }
identity_provider: authorizes the users who sign in to your app through their company’s identity provider, with no consent screen: RubyLLM exchanges the user’s ID token for a grant the server’s authorization server accepts, as the identity provider’s policy allows. Pass its issuer:, your app’s client_id: and client_secret: there, and the user’s id_token:. Values may be blocks or method names.
oauth owner: :user, client_id: ENV["WIKI_CLIENT_ID"], client_secret: ENV["WIKI_CLIENT_SECRET"], identity_provider: { issuer: "https://acme.okta.com", client_id: ENV["OKTA_CLIENT_ID"], client_secret: ENV["OKTA_CLIENT_SECRET"], id_token: -> { user.id_token } }
Source
# File lib/ruby_llm/mcp.rb, line 246 def only(*names) return @only if names.empty? @only = names.flatten.map(&:to_s) end
Limits the tools the model sees to the named server tools.
only :search_issues, :get_issue
Source
# File lib/ruby_llm/mcp.rb, line 268 def prefix(value = nil) return @prefix if value.nil? @prefix = value.to_s end
Prefixes the names of the server’s tools, so tools from servers that share names, such as two servers with a search tool, can join one chat. Tools renamed with ::tool keep the name you gave them.
prefix :github # search_issues becomes github_search_issues
# File lib/ruby_llm/mcp.rb, line 314 def requires_approval(*names, **options) unknown = options.keys - [:if] raise ArgumentError, "Unknown requires_approval options: #{unknown.join(', ')}" if unknown.any? @approvals = approvals + [[names.flatten.map(&:to_s), options[:if]]] end
Pauses the named server tools for approval before they run, using the flow of Tool.requires_approval. Without names, every tool needs approval. if: takes a Tool predicate, or a lambda that receives the tool:
requires_approval :create_issue, :merge_pull_request requires_approval if: :destructive?
Source
# File lib/ruby_llm/mcp.rb, line 223 def timeout(seconds = nil) return @timeout if seconds.nil? @timeout = seconds end
Sets how many seconds a request to the server may take. Defaults to the configured request_timeout.
timeout 30
# File lib/ruby_llm/mcp.rb, line 293 def tool(tool, as: nil, description: nil, fixed_arguments: nil, wrap: nil) @tool_declarations = tool_declarations.dup @tool_declarations << if tool.is_a?(Class) tool else [tool.to_s, { as:, description:, fixed_arguments:, wrap: }.compact] end end
Shapes a server tool, or adds one of your own.
Given a server tool’s name, as: renames it, description: rewrites what the model reads, and fixed_arguments: removes arguments from the model’s view and always sends your values, which may be lambdas. wrap: names a method that receives the server’s Result and the call’s arguments and returns what the model sees:
tool :search_files, as: :drive_search, description: "Search the user's Drive" tool :search_issues, fixed_arguments: { owner: "crmne", repo: "ruby_llm" } tool :read_file, wrap: :extract_text
Given a Tool class, adds it next to the server’s tools. The tool is created with this MCP when its initialize takes an argument, so it can call the server:
tool SearchWithPreviews
# File lib/ruby_llm/mcp.rb, line 111 def transport(value = nil, &block) return @transport if value.nil? && block.nil? @transport = block || value end
Sets the transport that carries the server’s JSON-RPC messages, for servers reached neither over Streamable HTTP nor over stdio. Pass the transport, a method name, or a block that returns one. Called with no argument, returns the configured value.
transport { Tunnel.new(device) }
A transport responds to four methods. request(message, version:, timeout:, headers:) sends a JSON-RPC request and returns the response as a Hash with string keys, yielding any notifications the server sends meanwhile. timeout is nil unless RubyLLM needs a shorter one than the transport’s own, and headers holds the tool arguments the server asks to receive as Mcp-Param-* HTTP headers. notify(message, version:) and cancel(notification, version:) send a notification, and close releases the connection until the next request. Raise MCP::Error when the server cannot be reached. The transport handles its own authentication and timeouts.
Source
# File lib/ruby_llm/mcp.rb, line 74 def url(value = nil) return @url if value.nil? @url = value end
Sets the server’s Streamable HTTP endpoint. Plain HTTP is only allowed for loopback addresses. Called with no argument, returns the configured value.
url "https://mcp.linear.app/mcp"
Public Instance Methods
Source
# File lib/ruby_llm/mcp.rb, line 559 def call(name, **arguments) outcome = call_tool({ name: name.to_s, arguments: }) Result.new(outcome.is_a?(Task) ? finish(outcome) : outcome, ui_uri_of(name)) end
Calls the server tool name with arguments and returns an MCP::Result. Every server tool is also a method:
linear.call(:list_issues, query: "bug") linear.list_issues(query: "bug")
When the server runs the call as a task, waits for it the way MCP::Task#wait does, and cancels it if waiting fails.
Raises MCP::Error when the server answers with a protocol error. A tool that fails returns a Result whose #error? is true.
Source
# File lib/ruby_llm/mcp.rb, line 760 def close @listener&.stop @client&.close end
Closes the connection, stopping a stdio server’s process and ending the session of a server that keeps one, and stops listening. The next request reconnects.
Source
# File lib/ruby_llm/mcp.rb, line 689 def instructions client.server['instructions'] end
Returns the instructions the server gives for using it, or nil.
# File lib/ruby_llm/mcp.rb, line 746 def listen(resources: [], tasks: []) uris = resources.map { |resource| resource.respond_to?(:uri) ? resource.uri : resource.to_s } ids = tasks.map { |task| task.respond_to?(:id) ? task.id : task.to_s } watched = listener.start(listened_changes(uris, ids)) || {} missing = (uris - Array(watched['resourceSubscriptions'])) + (ids - Array(watched['taskIds'])) return self if missing.empty? listener.stop raise Error, "#{name} does not send updates for #{missing.join(', ')}" end
Listens for the server’s changes in a background thread until close, so ::after_change callbacks run as changes happen and tools follows the server’s list. Pass resources, as URIs or MCP::Resource objects, to hear when their content changes, and tasks, as MCP::Task objects or their IDs, to hear when their status changes; a later call replaces them. Returns self once the server confirms.
handbook = Handbook.new.listen(resources: ["handbook://policies"]) reports.listen(tasks: chat.pending_tasks)
Without resources or tasks, does nothing for a server that announces no changes. Raises MCP::Error when the server cannot be reached or does not send updates for the resources or tasks.
Source
# File lib/ruby_llm/mcp.rb, line 525 def name self.class.default_name end
Returns the name that identifies this MCP in a chat, derived from the class name.
GoogleDrive.new.name # => "google_drive"
Source
# File lib/ruby_llm/mcp.rb, line 601 def prompt(name, **arguments) result = request('prompts/get', { name: name.to_s, arguments: arguments.transform_values(&:to_s) }) messages = result.fetch('messages', []).map do |message| content, attachments = Content.read([message['content']]) Message.new(role: message['role'].to_sym, content:, attachments:) end Prompt.new(self, { 'name' => name.to_s, 'description' => result['description'] }, messages:) end
Fills in the server prompt name with arguments and returns an MCP::Prompt with its messages, ready for Chat#ask.
chat.ask github.prompt(:code_review, code: diff)
Source
# File lib/ruby_llm/mcp.rb, line 592 def prompts client.list('prompts/list', 'prompts').map { |data| Prompt.new(self, data) } end
Returns the prompts the server offers, as MCP::Prompt objects.
Source
# File lib/ruby_llm/mcp.rb, line 576 def resource(uri, **variables) uri = ResourceTemplate.expand(uri, variables) unless variables.empty? contents = request('resources/read', { uri: }).fetch('contents', []) data = contents.find { |content| content['uri'] == uri } || contents.first raise Error, "#{name} returned no content for #{uri}" unless data Resource.new(self, data) end
Reads the resource at uri and returns an MCP::Resource. Given a template from resource_templates, variables fill it in.
files.resource("file:///project/README.md") files.resource("file:///{path}", path: "Gemfile")
Source
# File lib/ruby_llm/mcp.rb, line 587 def resource_templates client.list('resources/templates/list', 'resourceTemplates').map { |data| ResourceTemplate.new(self, data) } end
Returns the server’s resource templates as MCP::ResourceTemplate objects.
Source
# File lib/ruby_llm/mcp.rb, line 566 def resources client.list('resources/list', 'resources').map { |data| Resource.new(self, data) } end
Returns the resources the server lists, as MCP::Resource objects whose content is read when you first ask for it.
Source
# File lib/ruby_llm/mcp.rb, line 540 def tools @tools || remember(:@tools) do definitions = server_tools check_declared_tools(definitions) definitions.filter_map { |definition| shape(definition) } + added_tools end end
Returns the server’s tools, shaped by ::only, ::except, and ::tool, followed by the Tool classes added with ::tool. The server’s list is fetched once, and again after the server says it changed or answers a call with an error because the tool is gone.
The list includes the tools of an MCP App that only its UI may call, whose MCP::Tool#visibility leaves out :model. Chats never offer those to the model.
Raises ConfigurationError when a declaration names a tool the server does not offer.
Source
# File lib/ruby_llm/mcp.rb, line 694 def version server_info['version'] end
Returns the version the server reports for itself, or nil.