MCP Client
Connect your chats and agents to Model Context Protocol servers and use their tools, resources, and prompts
After reading this guide, you will know:
- How to connect to an MCP server from a Ruby class.
- How to explore and call a server’s tools from Ruby.
- How to choose, rename, wrap, and build on a server’s tools.
- How to give a server’s tools to chats, agents, and Rails records.
- How to read a server’s resources and ask with its prompts.
- How to answer a server’s questions and follow its progress and logs.
- How to keep up with servers whose tools and resources change.
- How to declare protocol extensions, host MCP Apps, and follow long tasks.
- How to authorize servers with OAuth.
- How RubyLLM talks to servers and keeps connections safe.
Describing a Server
Services such as Linear, GitHub, Notion, and Dropbox expose their APIs as Model Context Protocol servers. RubyLLM is an MCP client: it connects to those servers and uses what they offer. It does not build MCP servers.
Describe a server you want to connect to in a subclass of RubyLLM::MCP, the way you describe a tool or an agent:
class Linear < RubyLLM::MCP
url "https://mcp.linear.app/mcp"
bearer_token ENV.fetch("LINEAR_API_KEY")
end
url connects over Streamable HTTP, and it takes HTTPS. Plain HTTP works only for localhost and loopback addresses, so a local server needs nothing extra. For a server on a private network you trust, such as a Compose or Kubernetes service, opt in with allow_http: true:
class Tools < RubyLLM::MCP
url "http://tools:4011/mcp", allow_http: true
end
RubyLLM.mcp(url: "http://tools:4011/mcp", allow_http: true)
Without it, RubyLLM raises an ArgumentError that names the option. Nothing encrypts that traffic, so anyone on the network can read and change it, including any bearer_token you send. OAuth stays on HTTPS either way, and URLs still can’t carry credentials.
For a server that runs on your machine, give the command that starts it and RubyLLM talks to it over stdio:
class Files < RubyLLM::MCP
command "npx", "-y", "@modelcontextprotocol/server-filesystem", "."
directory Rails.root
env NODE_ENV: "production"
end
The process starts on the first request and stops when you call close.
A stdio server does not inherit your application’s API keys or the rest of its environment. It gets only what a process needs to start: HOME, LOGNAME, PATH, SHELL, TERM, USER, TMPDIR, LANG, LC_ALL, LC_CTYPE, GEM_HOME, GEM_PATH, and their Windows equivalents. Pass anything else with env, which also overrides those:
class GitHub < RubyLLM::MCP
command "docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"
env GITHUB_PERSONAL_ACCESS_TOKEN: ENV.fetch("GITHUB_TOKEN")
end
Values in env can also be blocks or method names that read inputs, such as env GITHUB_PERSONAL_ACCESS_TOKEN: -> { user.github_token } on a server that declares inputs :user. They run each time the process starts, so a server that starts again after close or a crash gets the current values.
In Rails, bin/rails generate ruby_llm:mcp Linear https://mcp.linear.app/mcp creates LinearMcp in app/mcp/linear_mcp.rb, or LinearMCP when your inflections make MCP an acronym, and --command instead of a URL creates a server started over stdio. The examples in this guide name a server after its service, such as Linear; with a generated class, use its name, such as LinearMcp.new(user: current_user).
Custom Transports
Some servers are reached neither over HTTP nor over stdio, such as a server on a user’s computer that dials out to your app through a tunnel. Give those a transport, an object that carries the JSON-RPC messages:
class LaptopFiles < RubyLLM::MCP
inputs :device
transport { Tunnel.new(device) }
end
A transport responds to four methods:
class Tunnel
def request(message, version:, timeout: nil, headers: {})
# Send the request and return the response as a Hash with string keys.
# Yield notifications the server sends while it works, such as progress.
end
def notify(message, version:) = send_message(message)
def cancel(notification, version:) = send_message(notification)
def close = disconnect
end
The transport handles its own authentication and timeouts. timeout is nil unless RubyLLM wants a shorter one than the transport’s own, and headers holds the tool arguments the server asks to receive as Mcp-Param-* HTTP headers, which a transport that doesn’t end in HTTP can ignore. After close, the next request reconnects. Raise RubyLLM::ConnectionError when the server can’t be reached, as the HTTP transport does. To let the model know instead, answer a tools/call request with a tool error, { "result" => { "isError" => true, "content" => [{ "type" => "text", "text" => "The laptop is offline" }] } }.
To support listening for changes, a transport also responds to listen(message, version:). It sends message, a subscriptions/listen request, yields each notification the server sends for it, and returns the server’s response once the server ends the subscription. RubyLLM calls it from the listener’s thread and raises RubyLLM::CancelledError there to stop it. For servers that predate the 2026-07-28 revision, message is nil, and the transport yields the changes the server announces on its own.
When your app also sends such a server requests of its own, outside RubyLLM, give them the _meta RubyLLM’s client adds to every request on the 2026-07-28 revision, so the server sees the same protocol revision and client name:
meta = RubyLLM::MCP::Client.new(tunnel).meta
params = { name: "roots", arguments: {}, _meta: meta }
On the 2026-07-28 revision, this client declares no capabilities, so the server won’t send those requests questions or extension features. A server on an older revision takes the client’s details and capabilities from its session’s initialize handshake instead, which meta doesn’t perform. Send it requests only in a session that has already been initialized, leave these fields out, and keep any _meta of the request’s own, such as a progressToken.
Inputs
Most servers act on behalf of a user. Declare inputs, and settings that depend on them take a block or a method name:
class Linear < RubyLLM::MCP
url "https://mcp.linear.app/mcp"
inputs :user
bearer_token { user.linear_token }
header "X-Workspace", :workspace_id
private
def workspace_id
user.workspace.linear_id
end
end
linear = Linear.new(user: current_user)
Blocks run on the instance whenever RubyLLM sends a request, so a token that refreshes is always current.
Inline Servers
When a class is not worth writing, build a server with RubyLLM.mcp:
docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
files = RubyLLM.mcp(command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."])
It takes the same settings as keywords: transport:, bearer_token:, headers:, env:, directory:, timeout:, prefix:, input_requests:, log_level:, extensions:, oauth:, and name:. That suits servers your users add at runtime:
RubyLLM.mcp(url: server.endpoint, name: "mcp_#{server.id}", prefix: "mcp_#{server.id}",
oauth: { owner: server })
A server with a transport: also needs a name:, since it has no URL or command to be named after.
Without a name:, a server is named after the host of its URL or after its command, so two servers started with node, or two on mcp.example.com, would share a name. Connecting both to one chat raises an ArgumentError that names the two servers; give one of them a name:.
Exploring a Server
A server is a Ruby object. Open a console and ask it what it can do:
>> docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp")
>> docs.tools
=> [#<RubyLLM::MCP::Tool name: "microsoft_docs_search", read_only: true>, ...]
>> docs.tools.first.description
=> "Search official Microsoft/Azure documentation..."
>> docs.instructions
=> "..."
Every tool on the server is also a method, the way Active Record turns columns into attributes:
result = docs.microsoft_docs_search(query: "Azure Blob Storage")
result.text # => "..."
result.structured # => { "results" => [...] }
result.attachments # => [#<RubyLLM::Attachment ...>]
result.meta # => { "com.microsoft/request_id" => "..." }
Use call when a tool’s name is not a valid Ruby method name:
docs.call("microsoft_docs_search", query: "Azure Blob Storage")
A RubyLLM::MCP::Result has the text, any images or files as attachments, and the structured content when the server sends it. error? tells you whether the tool reported a failure. meta is the _meta Hash the server attached, in the vocabulary of whichever extension wrote it, and empty when there is none.
Each tool also carries the server’s hints about its behavior: read_only?, destructive?, idempotent?, and open_world?. They come from the server, so trust them as far as you trust the server. A tool’s meta is the _meta of its definition.
Shaping Tools
A server hands the model every tool it has, often low-level ones written for any client. Decide what your model sees in the class.
Choosing Tools
Keep the tools you need with only, or hide some with except:
class GitHub < RubyLLM::MCP
url "https://api.githubcopilot.com/mcp/"
bearer_token ENV.fetch("GITHUB_TOKEN")
only :search_issues, :get_issue, :create_issue
end
Prefixing Names
Two servers can offer tools with the same name, such as search. Prefix one server’s tools so both fit in a chat:
class GitHub < RubyLLM::MCP
url "https://api.githubcopilot.com/mcp/"
prefix :github # search_issues becomes github_search_issues
end
Tools you rename with tool :x, as: keep the name you gave them.
Renaming and Describing
Give a tool the name and description that fit your agent:
tool :search_issues, as: :search_ruby_llm_issues,
description: "Search RubyLLM's issues. Use GitHub search syntax."
Fixed Arguments
Take an argument away from the model and always send your value:
tool :search_issues, fixed_arguments: { owner: "crmne", repo: "ruby_llm" }
The model sees search_issues without owner and repo, so it cannot search anywhere else. Use a lambda when a value depends on inputs: fixed_arguments: { repo: -> { user.default_repo } }.
Wrapping Results
Name a method with wrap: to decide what the model sees. It receives the server’s result and the arguments of the call:
class GoogleDrive < RubyLLM::MCP
url "https://drivemcp.googleapis.com/mcp/v1"
inputs :user
bearer_token { user.google_token }
tool :read_file, as: :drive_read, wrap: :cite
private
def cite(result, file_id:)
RubyLLM::SearchResults.new(title: file_id, url: "https://drive.google.com/open?id=#{file_id}",
text: result.text)
end
end
The method runs on the MCP instance, so inputs like user are available. A result the server marks as an error goes to the model as an error without passing through the method.
Adding Your Own Tools
Build higher-level tools from the server’s primitives with a regular RubyLLM::Tool. When its initialize takes an argument, it receives the MCP. In a Rails app, it can live next to the server in app/mcp/dropbox/search_and_read.rb:
class Dropbox::SearchAndRead < RubyLLM::Tool
description "Search Dropbox and read the best match"
parameter :query, description: "What to look for"
def initialize(dropbox)
@dropbox = dropbox
end
def execute(query:)
match = @dropbox.search(query:, max_results: 1).structured["matches"].first
return "Nothing found" unless match
@dropbox.get_file_content(path_or_file_id: match["file_id"]).text
end
end
Add it to the server in app/mcp/dropbox.rb:
class Dropbox < RubyLLM::MCP
url "https://mcp.dropbox.com/mcp"
inputs :user
bearer_token { user.dropbox_token }
only :search
tool SearchAndRead
end
The server’s tools stay callable as methods even when only hides them from the model.
Requiring Approval
Pause tools for a human decision with requires_approval. It uses the same flow as tools that require approval, including persisted decisions in Rails:
requires_approval :create_issue, :merge_pull_request
requires_approval if: :destructive?
requires_approval if: ->(tool) { tool.name.start_with?("delete") }
Without names, every tool needs approval. if: takes a tool predicate or a lambda that receives the tool.
Deferring Tools
A server with many tools fills the model’s context before it reads your question. Defer them, and the model searches for the ones it needs:
defer :search_code, :list_workflows
defer
Without names, every tool is deferred. See Tool Search.
Every name you declare must exist on the server. When a server stops offering one, RubyLLM raises RubyLLM::ConfigurationError as the tools load, instead of silently changing what the model sees.
Handling Tool Errors
A call the server fails, because it can’t be reached, answers that it is unavailable, or answers with a protocol error, gives the model a tool error saying so, and the chat goes on. See When a Server Is Down. To hand the model a result of your own, or to stop the chat, declare rescue_from on the server’s class:
class Linear < RubyLLM::MCP
url "https://mcp.linear.app/mcp"
inputs :user
rescue_from RubyLLM::MCP::Error, with: :server_refused
private
def server_refused(error)
{ error: "Linear couldn't answer: #{error.message}" }
end
end
The handler runs on the MCP instance, so declared inputs are available, and its return value becomes the call’s result. Re-raise to stop the chat. It works like rescue_from on a tool, and comes before a handler declared on RubyLLM::MCP::Tool. A handler on RubyLLM::Tool, which every tool inherits, doesn’t take a server’s failure from RubyLLM, so a catch-all there leaves outages handled as described below. Tool classes added with tool use their own handlers.
Using Servers in Chats
Connect servers to a chat with with_mcp, and the model can call their tools:
chat = RubyLLM.chat.with_mcp(Linear.new(user: current_user), Files)
chat.ask "Which open issues mention the flaky login spec?"
with_mcp accepts instances and classes. RubyLLM contacts a server the first time the chat needs its tools, so building a chat stays fast. Read connected servers by name, and pass nil to disconnect them:
chat.mcp.linear # => #<Linear ...>
chat.mcp[:files] # => #<Files ...>
chat.with_mcp(nil)
Disconnecting closes the servers the chat built from a class, which stops a stdio server’s process and ends an HTTP session. So does connecting another server under the same name. An instance you pass in stays open for the other chats that use it, until you call close on it.
Two tools with the same name raise an ArgumentError when the chat collects its tools. Pass defer: true to with_mcp to defer every tool of the servers you connect; see Tool Search.
Agents
Agents declare servers with mcp. A block runs when the chat is built, with the agent’s inputs available:
class TriageAgent < RubyLLM::Agent
inputs :user
instructions "Triage incoming bug reports."
mcp { [Linear.new(user: user), Files] }
end
TriageAgent.chat(user: current_user).ask "Triage the new reports"
Rails
Chat records respond to with_mcp and mcp like plain chats, and agents with a chat_class connect their servers to the records they create and find. Tool calls persist like any other tool call.
Resources
Servers also offer resources: files, records, or any data they name with a URI. They are files as far as RubyLLM is concerned, so they go wherever attachments go:
files.resources
# => [#<RubyLLM::MCP::Resource uri: "file:///project/README.md", name: "README.md", mime_type: "text/markdown">, ...]
readme = files.resource("file:///project/README.md")
readme.content # => "# My Project..."
readme.save("README.md")
chat.ask "Summarize this", with: readme
Resources from resources are read from the server the first time you need their content. content is text for a text resource and bytes for a binary one, and meta is the _meta the server sent: a resource you read has the _meta of its contents, one from resources has the _meta of the listing.
Some servers describe families of resources with URI templates. Fill one in with keywords:
files.resource_templates
# => [#<RubyLLM::MCP::ResourceTemplate uri: "file:///{+path}", name: "Project files">]
files.resource("file:///{+path}", path: "app/models/user.rb")
RubyLLM never fetches a resource’s URI directly, even when it is an https link. Every read goes through the server.
Prompts
Servers can offer prompts: messages the server writes, filled in with your arguments. Ask a chat with one:
github.prompts
# => [#<RubyLLM::MCP::Prompt name: "code_review", arguments: [:code, :language]>]
chat.ask github.prompt(:code_review, code: diff, language: "Ruby")
A prompt can hold several turns, including assistant messages, and ask adds all of them before the model answers. Read them with messages.
Prompts are meant to be chosen by people, like slash commands. To help users fill in arguments, ask the server for suggestions. The first keyword is the value to complete; the rest give context:
review = github.prompts.first
review.suggest(language: "ru") # => ["ruby", "rust"]
files.resource_templates.first.suggest(path: "app/mo")
Questions from Servers
A server can stop in the middle of a call to ask the user something: which environment to deploy to, or to visit a page and connect an account. Its question arrives as a RubyLLM::Question, the same object a tool’s own ask makes, and you answer it the same way.
In a chat, the question pauses the tool call. Show it to the user, record the answer, and resume:
chat.ask "Deploy the release branch"
question = chat.pending_questions.first
question.message # => "Which environment?"
question.fields.first.choices # => ["staging", "production"]
question.answer "staging" # or question.decline
chat.complete
A question describes what it asks for in fields, each with a name, type, title, description, choices, and default, and required?. Show each default in your form: the fields an answer leaves out take theirs. A question with one field takes its value, and one with several takes them by name: question.answer(environment: "staging", reason: "Release 2.2"). A question with a url asks the user to visit it, and answer with no value means they agreed to go. Show the whole address, since the server chose it. RubyLLM declines a request to visit anything but a web page, an http or https address, so a link to it can’t run script in your app. decline refuses either kind.
complete resumes the call once all its questions are answered: RubyLLM sends the answers with the server’s saved request state, and the server finishes, or asks something more. Then the call pauses again with the new questions, so check pending_questions after each complete and answer until none are left. Until then the chat is waiting?, as it is while a call waits for approval. Calling a tool outside a chat raises RubyLLM::MCP::InputRequiredError instead, with the unanswered questions in requests.
To answer for the user, declare before_input_request, a method name or a block that receives each question before it reaches the chat:
class Deploys < RubyLLM::MCP
url "https://deploys.example.com/mcp"
inputs :user
before_input_request :answer_from_settings
private
def answer_from_settings(question)
field = question.fields.first if question.fields.one?
if question.url?
question.decline
elsif field&.name == :environment && Array(field.choices).include?(user.default_environment)
question.answer(user.default_environment)
end
end
end
A question the callback leaves unanswered reaches the chat. An answer the question can’t take, such as a field it doesn’t ask for or a choice it doesn’t offer, raises ArgumentError from the call, so answer only the questions you recognize, with values they offer.
A paused chat waits until someone answers. If your app has nowhere to show a kind of question, tell servers not to ask it:
class Deploys < RubyLLM::MCP
url "https://deploys.example.com/mcp"
input_requests :url
end
RubyLLM accepts form and URL questions by default. input_requests :form or input_requests :url keeps one kind, and input_requests false accepts none, so servers finish the call without asking or answer with an error that raises RubyLLM::MCP::Error. A server that asks a kind you left out gets a decline. Inline servers take the same setting: RubyLLM.mcp(url: server.endpoint, input_requests: false).
In Rails, the questions persist on the tool call, so a job can pause, a controller can record the user’s answer, and another job can resume the call after a deploy or a restart. Answering never connects to the server. See Questions in Rails.
Servers never ask for passwords or tokens through forms; those go through URL questions, so they never pass through your application.
Progress and Cancellation
Servers can report progress while they work. Register after_progress with a method name or a block. It runs on the MCP instance with a RubyLLM::Progress:
class Deploys < RubyLLM::MCP
url "https://deploys.example.com/mcp"
inputs :chat
after_progress :broadcast_progress
private
def broadcast_progress(progress)
Turbo::StreamsChannel.broadcast_update_to chat, target: "status", html: progress.message
end
end
progress.value only grows, progress.total is set when the server knows how much work there is, and progress.fraction gives the share done.
In a chat, the progress of a server tool call also reaches after_tool_progress with the tool call, the same way a Ruby tool’s reports do:
chat.with_mcp(Deploys.new(chat:)).after_tool_progress do |tool_call, progress|
puts "#{tool_call.name}: #{progress.message}"
end
RubyLLM asks the server for progress only when an after_progress or after_tool_progress callback listens.
Servers can also write log messages while they work on a request. Ask for them with log_level, and RubyLLM writes the messages at that level and above to its logger, under the server’s name:
class Deploys < RubyLLM::MCP
url "https://deploys.example.com/mcp"
log_level :warning
end
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.
Cancelling a chat also stops the server call it is waiting on, with no threads involved. chat.cancel, or the persisted cancellation flag on a Rails chat record, takes effect at the next event the server streams. When tool calls run concurrently, every server call in flight stops. Over HTTP, RubyLLM closes the response stream, which is how the 2026-07-28 revision cancels a request; stdio servers and older HTTP servers receive a cancellation notice. A server that answers with a single response and no events cannot be interrupted, so it stops at the request timeout.
Listening for Changes
A server’s tools can change while you use it: a user connects another account, or the server adds tools when the model asks for them. Its resources change too, such as a file it serves.
When a server says its tools changed, RubyLLM forgets the list it fetched, so the next turn of a chat lists them again. Servers that predate the 2026-07-28 revision may say so while they answer any request, and RubyLLM follows them with no setup. Newer servers only tell clients that listen. RubyLLM also forgets the list when a server answers a call with an error because the tool is gone.
Reacting to Changes
Register after_change to act on changes yourself. It takes a method name or a block, runs on the MCP instance, and receives what changed: :tools, :prompts, or :resources when one of the server’s lists changes, the RubyLLM::MCP::Resource whose content changed, or the RubyLLM::MCP::Task whose status changed when you listen to tasks:
class Handbook < RubyLLM::MCP
url "https://handbook.example.com/mcp"
after_change :announce
private
def announce(change)
case change
when :tools then puts "Tools now: #{tools.map(&:name).join(", ")}"
when RubyLLM::MCP::Resource then puts "#{change.uri} now reads: #{change.content}"
end
end
end
The resource reads its new content from the server the first time you ask for it.
Listening in the Background
Call listen to hear about changes as they happen, between requests too:
handbook = Handbook.new.listen(resources: ["handbook://policies"])
listen asks the server for every list it announces changes to, for updates to the resources you pass, as URIs or as resources from resources, and for the status of the tasks you pass, as Tasks describes. It returns once the server confirms, then listens in a thread of its own until you call close, while chats and requests go on as before. Calling listen again replaces the resources and tasks it listens to.
A server that announces no changes leaves listen nothing to do. When a server over HTTP cannot be reached, listen raises RubyLLM::ConnectionError. When it will not send updates for a resource or task you pass, listen raises RubyLLM::MCP::Error.
Callbacks for the changes a listener hears run in its thread, one at a time, so keep them short. They can call the server, as tools and content do above. An exception in one is logged, and listening goes on.
When the connection drops or the server ends the subscription, RubyLLM subscribes again after about a second, doubling the wait up to a minute while the server stays away. Changes made in between are lost, so once it is back, RubyLLM treats everything it listens to as changed: it lists tools again, and after_change runs for each list the server announces changes to, each resource, and each task, which it checks on first. Write callbacks that can run again for something that didn’t change.
Older servers send their changes on their session’s event stream, and RubyLLM subscribes to resources with resources/subscribe; over stdio, the listener shares the server’s pipe with your requests. listen works the same either way.
Running a Listener
A listener holds a connection and a thread for as long as it runs, so start one where something owns it, and close it there. Requests and jobs rarely need one, since an MCP built for them lists the server’s tools fresh. When your app should react to changes as they happen, run the listener in a process of its own:
# script/listen_to_handbook.rb
handbook = Handbook.new.listen(resources: ["handbook://policies"])
begin
sleep
ensure
handbook.close
end
# Procfile
handbook: bin/rails runner script/listen_to_handbook.rb
In Rails, callbacks run inside the executor, like a job, so they can use Active Record and enqueue work:
class Handbook < RubyLLM::MCP
url "https://handbook.example.com/mcp"
bearer_token Rails.application.credentials.handbook_token
after_change :reindex
private
def reindex(change)
ReindexPolicyJob.perform_later(change.uri) if change.is_a?(RubyLLM::MCP::Resource)
end
end
A forked process does not inherit the listener’s thread. If forked workers should listen too, call listen after forking, such as in Puma’s on_worker_boot.
Extensions
Extensions add features to the protocol that a client and a server both opt into. Declare the ones your app supports with extension, the extension’s name and its settings:
class Deploys < RubyLLM::MCP
url "https://deploys.example.com/mcp"
extension "com.example/audit", level: "full"
end
An extension’s name starts with its vendor’s prefix, and its settings belong to the extension, so they go to the server as you write them. RubyLLM declares your extensions with every request, and in the handshake with servers that predate 2026-07-28. A server that doesn’t know an extension ignores it.
Inline servers take extensions:, a name or a Hash of names to settings:
RubyLLM.mcp(url: server.endpoint, extensions: { "com.example/audit" => { level: "full" } })
RubyLLM implements the official extensions it knows by name. Declare those with a Symbol. It also declares the authorization extensions your OAuth settings use.
MCP Apps
MCP Apps let a server ship a UI with its tools: an HTML page, such as a chart or a form, that your app shows next to a tool’s results. Declare :apps, and the server lists the tools that come with one:
class Weather < RubyLLM::MCP
url "https://weather.example.com/mcp"
inputs :user
bearer_token { user.weather_token }
extension :apps
end
weather = Weather.new(user: current_user)
forecast = weather.tools.find { |tool| tool.name == "get_forecast" }
forecast.ui_uri # => "ui://weather/forecast"
forecast.visibility # => [:model, :app]
The UI is a resource. Read it like any other, with its content security policy in meta:
view = weather.resource(forecast.ui_uri)
view.mime_type # => "text/html;profile=mcp-app"
view.content # => "<!DOCTYPE html>..."
view.meta["ui"] # => { "csp" => { "connectDomains" => ["https://api.weather.example"] }, "prefersBorder" => true }
Rendering the UI is your app’s job. Following the MCP Apps spec, it runs in a sandboxed iframe on an origin separate from your app, inside a second iframe whose content security policy you build from meta["ui"]["csp"], and your page passes it the tool’s arguments and result.
In a chat, the model calls a tool with a UI like any other tool, and sees its text. That includes a failed call, which a server may answer with an error while its UI asks the user for something, so the UI can render then too. The UI needs more, such as the structured content, so the tool result message keeps the server’s whole result as mcp_result. In Rails it persists on the tool call, so a UI renders the same after a reload. A controller can give your page everything it needs:
class Weather::ViewsController < ApplicationController
def show
message = Message.where(chat: Current.user.chats).find(params[:message_id])
result = message.mcp_result
view = Weather.new(user: Current.user).resource(result.ui_uri)
render json: {
html: view.content,
csp: view.meta.dig("ui", "csp"),
input: message.parent_tool_call.arguments,
result: result.to_h
}
end
end
Your page loads html into the sandbox, then sends the UI input and result as the spec’s tool input and tool result notifications. mcp_result is nil for tools without a UI, whose results are only what the model saw, and for failed calls, whose content is { "error": ... } as for any tool. New applications get the mcp_result column from ruby_llm:install; applications that installed RubyLLM 2.0 add it with bin/rails generate ruby_llm:upgrade.
Some tools exist only for their UI, such as the one behind a refresh button. Their visibility leaves out :model: tools lists them, and you can call them, but chats never offer them to the model, even when you pass them to with_tools.
When the UI calls a tool, your page sends the call to a controller, which makes it on the UI’s behalf. Look the tool up by its name on the server, and refuse it unless its visibility includes :app:
class Weather::ToolCallsController < ApplicationController
def create
weather = Weather.new(user: Current.user)
tool = weather.tools.find { |tool| tool.server_name == params[:name] }
return head :forbidden unless tool&.visibility&.include?(:app)
arguments = params.fetch(:arguments, {}).permit!.to_h.symbolize_keys
render json: weather.call(tool.server_name, **arguments).to_h
end
end
call returns the whole result, and to_h is the result as the server sent it, content, structured content, and _meta included, which is what the UI expects.
Tasks
Some tools take minutes, such as rendering a report or running a deploy. With the Tasks extension, a server answers such a call with a task and does the work in the background. Declare :tasks:
class Reports < RubyLLM::MCP
url "https://reports.example.com/mcp"
extension :tasks
end
A chat never waits for a task. The tool call pauses, the way tools that require approval and questions do, and complete returns:
chat = RubyLLM.chat.with_mcp(Reports)
chat.ask "Render the quarterly report"
chat.waiting? # => true
task = chat.pending_tasks.first
task.status # => :working
task.status_message # => "Rendering page 3 of 12"
task.poll_interval # => 5.0
Call complete again later. The chat is waiting? until then, as it is for approvals and questions. complete checks on each task once, without waiting, and resumes the chat when they’re done, so the model sees their results. In Rails, the task persists on its tool call, so one job can pause the chat and another can check on it, even after a deploy. Schedule the next check from the task’s poll interval:
class CheckTasksJob < ApplicationJob
def perform(chat_id)
chat = ReportsAgent.find(chat_id)
chat.complete
return if chat.pending_tasks.none?
wait = chat.pending_tasks.filter_map(&:poll_interval).min || 5
CheckTasksJob.set(wait: wait.seconds).perform_later(chat_id)
end
end
Your own tools can pause the same way by returning a RubyLLM::Task; see Pausing a Tool Call.
refresh checks on a task without resuming the chat, which suits a progress indicator. complete?, succeeded?, failed?, and cancelled? tell where it stands, result is its RubyLLM::MCP::Result once it completes, and expires_at is when the server may forget it. Every check also reports the task’s status message to after_progress and after_tool_progress.
Servers can also announce when the status of a task changes, so you check on it when there is news instead of on a schedule. Pass the tasks to listen, and after_change receives each RubyLLM::MCP::Task as it stands, with its result once it completes:
class Reports < RubyLLM::MCP
url "https://reports.example.com/mcp"
extension :tasks
after_change { |change| ReportReadyJob.perform_later(change.id) if change.is_a?(RubyLLM::MCP::Task) && change.complete? }
end
reports = Reports.new.listen(tasks: chat.pending_tasks)
A server decides whether it announces the status of a task, and servers that predate 2026-07-28 never do, so listen raises RubyLLM::MCP::Error for tasks it won’t hear about. Checking with complete works either way.
A task that needs input pauses the chat on questions: answer them and call complete. A task that fails raises RubyLLM::MCP::Error from complete. When checking on a task raises, a Rails chat keeps the paused tool call and its task, so complete checks again, and cancel drops them. Cancelling the chat cancels its tasks the next time it runs, so after chat.cancel, or the persisted flag on a Rails record, complete cancels them and raises RubyLLM::CancelledError. task.cancel asks the server right away; the server may still finish the task.
Outside a chat, call waits for the task of a tool you call directly, and wait waits for any task, both up to the server’s timeout:
reports.render_report(quarter: "Q3").text
task.wait.result
Authorization
A server that belongs to a service your app already signs users into can take that token with bearer_token. For everything else, MCP servers use OAuth, and RubyLLM runs it for you:
class Linear < RubyLLM::MCP
url "https://mcp.linear.app/mcp"
inputs :user
oauth owner: :user
end
owner: names whose credentials these are, usually an input. RubyLLM finds the server’s authorization server, registers itself when needed, and uses PKCE, as the MCP authorization spec requires. When the MCP server names its authorization server, RubyLLM uses that server’s metadata only when the issuer it names is that server’s own URL, and raises RubyLLM::MCP::Error otherwise. Send the user to authorize, then finish in the callback:
class LinearConnectionsController < ApplicationController
def new
redirect_to linear.authorization_url(redirect_uri: callback_linear_connection_url), allow_other_host: true
end
def callback
linear.authorize(params)
redirect_to root_path, notice: "Linear is connected."
end
private
def linear
Linear.new(user: Current.user)
end
end
From then on, every request carries the user’s token, and RubyLLM refreshes it when it expires. Check linear.authorized? before showing a chat that needs it, and call linear.deauthorize to forget the credentials. authorize raises RubyLLM::MCP::Error when the callback does not belong to the authorization it started, such as a wrong state or another issuer.
Some servers, such as Slack’s, only accept apps you registered with them. Pass that app’s credentials:
class Slack < RubyLLM::MCP
url "https://mcp.slack.com/mcp"
inputs :user
oauth owner: :user, client_id: ENV["SLACK_CLIENT_ID"], client_secret: ENV["SLACK_CLIENT_SECRET"]
end
An app belongs to the authorization server you registered it with. RubyLLM remembers that server the first time it uses the app’s credentials, and if the MCP server later names another one, it raises RubyLLM::MCP::Error instead of sending them there. Register an app with the new authorization server and pass its credentials.
Some authorization servers need extra parameters on the authorization URL. Google, for example, issues a refresh token only with access_type=offline, and issues one again only with prompt=consent, so a Google-hosted MCP server would otherwise lose its grant after an hour. Pass them as authorization_params::
class BigQuery < RubyLLM::MCP
url "https://bigquery.googleapis.com/mcp"
inputs :user
oauth owner: :user, client_id: ENV["GOOGLE_CLIENT_ID"], client_secret: ENV["GOOGLE_CLIENT_SECRET"],
authorization_params: { access_type: "offline", prompt: "consent" }
end
They can be a block or method name, like other oauth options. RubyLLM raises ArgumentError for the parameters it sets itself, such as client_id, state, and scope.
Pass scopes: to ask for specific scopes instead of the ones the server suggests.
A server that needs a scope the token lacks raises RubyLLM::MCP::InsufficientScopeError, a RubyLLM::ForbiddenError whose message carries the server’s reason and whose scopes lists what it asked for. RubyLLM never asks for wider access by itself, so a model’s tool call cannot grant itself more permissions. Decide which scopes your app needs, add them to scopes:, and send the user through authorization_url again:
class GitHub < RubyLLM::MCP
url "https://api.githubcopilot.com/mcp/"
inputs :user
oauth owner: :user, client_id: ENV["GITHUB_CLIENT_ID"], client_secret: ENV["GITHUB_CLIENT_SECRET"],
scopes: %w[read:user repo]
end
begin
chat.ask "List the open issues in crmne/ruby_llm"
rescue RubyLLM::MCP::InsufficientScopeError => e
e.message # => "api.githubcopilot.com refused the request: Additional scopes required: repo"
e.scopes # => ["repo"]
end
Without a User
Background jobs and services often connect as your app rather than on behalf of a user. When the authorization server issued your app its own credentials, use the client credentials grant:
class Reports < RubyLLM::MCP
url "https://mcp.example.com/mcp"
oauth grant: :client_credentials, client_id: ENV["REPORTS_CLIENT_ID"], client_secret: ENV["REPORTS_CLIENT_SECRET"]
end
Reports.new.tools
No one signs in. RubyLLM requests a token the first time the server asks for one, and a new one before it expires. When the authorization server refuses, the request raises RubyLLM::UnauthorizedError with its reason. Every request declares the OAuth Client Credentials extension, which servers that require it check.
Authorization servers that accept signed assertions let you register a public key instead of sharing a secret. Pass the private key, as a PEM string or an OpenSSL::PKey, and RubyLLM signs a short-lived assertion for each token request:
oauth grant: :client_credentials, client_id: "reports", private_key: Rails.application.credentials.reports_private_key
private_key: works for any app you registered, including one your users authorize.
A workload that already holds a token from its platform, such as a Kubernetes service account token or a SPIFFE JWT, can present that token instead and needs no client of its own:
class Reports < RubyLLM::MCP
url "https://mcp.example.com/mcp"
oauth assertion: -> { File.read("/var/run/secrets/tokens/mcp-token") }
end
RubyLLM presents the assertion: with the JWT bearer grant. A block or method name is read again for every token, since platforms rotate them. The authorization server decides which platforms and workloads it trusts.
Enterprise Single Sign-On
When your users sign in to your app through their company’s identity provider, such as Okta, the company can decide which MCP servers they reach, and they skip the authorization screens. Pass the identity provider and the ID token from the user’s sign-in:
class Wiki < RubyLLM::MCP
url "https://wiki.example.com/mcp"
inputs :user
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.okta_id_token } }
end
The outer client_id: and client_secret: are your app’s registration with the server’s authorization server, and the ones under identity_provider: are its registration with the identity provider. Like other settings, the values may be blocks or method names.
RubyLLM exchanges the ID token at the identity provider for a grant addressed to the server’s authorization server, then exchanges that grant for a token. The identity provider’s policy decides who reaches which servers, and a refusal raises RubyLLM::UnauthorizedError. Every new token needs a current ID token, so refresh it the way your sign-in does. Every request declares the Enterprise-Managed Authorization extension, which servers that require it check.
Proof-of-Possession Tokens
Some servers only accept tokens bound to a key, so a token that leaks is useless without the key. When a server’s challenge or metadata requires DPoP, RubyLLM binds new tokens to a key it creates, keeps the key with the credentials, and signs every token request and every request to the server with it. There is nothing to configure.
Storing Credentials
In Rails, credentials live in the ruby_llm_mcp_credentials table, encrypted with Active Record encryption. New applications get the table from ruby_llm:install; applications that installed RubyLLM 2.0 add it with the upgrade generator:
bin/rails generate ruby_llm:upgrade
bin/rails db:encryption:init # if your app has no encryption keys yet
bin/rails db:migrate
Plain Ruby keeps credentials in memory. Set config.mcp_credential_store to an object with read(key), write(key, data, owner:), and delete(key) to keep them elsewhere.
Some authorization servers rotate refresh tokens and reject one that was used twice, so RubyLLM refreshes each grant in one place at a time. Threads take turns, and a worker that waited uses the token the first one received. In Rails, a row lock does the same across processes. A store of your own that several processes share should also respond to synchronize(key), running the block while no other process holds that key.
Client Registration
Authorization servers that support client ID metadata documents can identify your app by a URL instead of a registration. Serve the document from your app and point RubyLLM at it:
RubyLLM.configure do |config|
config.mcp_client_id = "https://app.example.com/oauth/client.json"
config.mcp_client_name = "Example"
end
The document’s client_id must be that exact URL, and its redirect_uris must list your callback. Without it, RubyLLM registers with servers that allow dynamic registration, once per authorization server and callback. When an authorization server forgets a registration and answers invalid_client, RubyLLM registers again the next time a user authorizes.
Connections and Safety
RubyLLM speaks the 2026-07-28 revision of the protocol, where every request stands alone. For servers that predate it, back to 2024-11-05, RubyLLM falls back to the older handshake and declares nothing but extensions, so those servers never send requests back. Should one ask anyway, RubyLLM answers right away that it doesn’t support the request, so the call goes on. When such a server ends its session, or its process exits, RubyLLM starts a new session and sends the request again, and close ends the session. Connecting to a server that speaks none of these revisions raises RubyLLM::MCP::Error.
A response stream can break before the answer arrives, such as when a proxy drops a long call. RubyLLM then sends the request again, as the 2026-07-28 revision requires, unless it is a tool call. The server may already have run the tool, so RubyLLM sends a call again only when the server marks the tool idempotent?. Older servers can resume the stream instead: RubyLLM waits as long as the server asks and reconnects from the last event it received. Either way it tries three times at most, then raises RubyLLM::MCP::Error.
Some defaults protect applications that connect to servers they do not control:
- Plain HTTP is only allowed for
localhostand loopback addresses. Everything else needs HTTPS, unlessallow_http: truesays the network is trusted. - Redirects are never followed.
- Request logs never include headers, so tokens stay out of your logs.
When a Server Is Down
An MCP server that is down doesn’t stop the chat. A server is down when listing its tools can’t reach it or it says it can’t serve right now: the connection is refused, the server never answers, it answers HTTP 429 or a 5xx status, or its stdio process exits or doesn’t answer in time. The turn goes on without that server’s tools. RubyLLM logs a warning naming the server and the error’s class or HTTP status, emits a mcp_unreachable.ruby_llm event with the server’s name, the error’s class and the HTTP status, never the error’s message, and adds a line to the request telling the model the server’s tools are unavailable, so it can say so instead of guessing. That line never joins chat.messages.
RubyLLM waits a minute before it tries that server again, across the whole process, so a server that never answers doesn’t hold up every request. A server that answers with a Retry-After header waits as long as it asks instead. A server whose url or command is a block or method name, or that uses a transport, waits per object instead, since it may be a different server for each user. Tools that were already listed stay available in that chat while other chats find the server down.
A tool call the server fails doesn’t stop the turn either. When the call can’t reach the server, the server answers HTTP 429 or a 5xx status, or it answers with a JSON-RPC error, the model gets a tool error saying the server failed, with what the server wrote even when redact_error_messages is on, as it does for a tool error the server reports, and the turn goes on. When the call broke off or the server answered a 5xx status, the server may have started the work, so for a tool that isn’t read-only, unless it is idempotent and not destructive, the error also tells the model the call may have partly run and to check before trying again. A server that is down also sits out the chat’s next requests, with the same warning, event, and backoff, and until the backoff ends a call to one of its tools, even one you passed to with_tools, gets that tool error without contacting it. A handler you declare with rescue_from comes first when the server request raises, but not for a call skipped during the backoff. Checking on a task the server runs still raises, since the task may still be running.
Building a chat or an agent never contacts its MCP servers, and neither does finding a saved one with find. They are contacted when the chat first needs their tools, to make a request or to read chat.tools. A tool choice that names a server’s tool is checked then too, and while that server is down the request raises RubyLLM::InvalidToolChoiceError naming it, since the chat can’t use the tool it was told to. So does choice: :required when the servers that are down leave the chat no tools. So the pages that show a conversation, list its pending_questions, or answer, approve, and decline them keep working while a server is down.
Calling the server directly, such as linear.tools or linear.search_issues, still raises. So does a refusal, such as RubyLLM::UnauthorizedError or a 404, from a chat too, since retrying won’t fix those.
Which Addresses a Server May Reach
RubyLLM does not check where a hostname resolves. When your users add servers, connect through a context whose faraday_adapter decides which addresses the app may reach. The context’s connection settings carry every request to the server and every OAuth request its authorization makes, while chats keep the global ones:
mcp_context = RubyLLM.context { |config| config.faraday_adapter = PublicAddressesOnly }
mcp_context.mcp(url: server.endpoint, prefix: "mcp_#{server.id}", oauth: { owner: server })
Linear.new(user: current_user, context: mcp_context)
Requests use the configured request_timeout. Set timeout on a server to change it:
class Linear < RubyLLM::MCP
url "https://mcp.linear.app/mcp"
timeout 30
end
Outside a chat’s tool calls, a server that answers with a protocol error raises RubyLLM::MCP::Error, whose code is the JSON-RPC error code. A server that wants credentials raises RubyLLM::UnauthorizedError. A server that refuses a request raises RubyLLM::ForbiddenError, or RubyLLM::MCP::InsufficientScopeError when the token lacks a scope.
Next Steps
- Controlling Tool Execution for approvals and concurrency
- Provider Tools for MCP servers the provider connects to
- Agents for reusable chat configurations