Model Context Protocols (MCP)
Connect agents to external services via Model Context Protocol servers. MCP servers expose tools and data sources that agents can use automatically.
Quick Start
class WeatherAgent < ActiveAgent::Base
generate_with :anthropic, model: "claude-haiku-4-5"
def forecast
prompt(
"What's the weather like?",
mcps: [ { name: "weather", url: "https://demo-day.mcp.cloudflare.com/sse" } ]
)
end
endProvider Support
All providers accept mcps:. 🟩 means the provider runs a remote url: server; 🟦 means ActiveAgent runs it and exposes its tools as functions. A local command: server is always 🟦. Mock accepts declarations but does not call tools.
| Provider | url: servers | command: servers | Notes |
|---|---|---|---|
| Anthropic | 🟩 | 🟦 | Provider support is beta |
| Azure | 🟦 | 🟦 | |
| DeepSeek | 🟦 | 🟦 | Ignores mcp_servers rather than rejecting it |
| Gemini | 🟦 | 🟦 | |
| Mock | 🟦 | 🟦 | Accepted, but Mock never emits tool calls |
| Ollama | 🟦 | 🟦 | |
| OpenAI (Chat Completions) | 🟦 | 🟦 | |
| OpenAI (Responses API) | 🟩 | 🟦 | |
| OpenRouter | 🟦 | 🟦 | |
| Requesty | 🟦 | 🟦 | |
| RubyLLM | 🟦 | 🟦 |
MCP Format
A server uses either an HTTP url: or a local stdio command:.
# Remote server, over HTTP
{
name: "server_name", # Optional: server identifier, defaults to the host
url: "https://server.url", # Required: MCP endpoint
authorization: "token", # Optional: auth token
read_timeout: 10, # Optional: seconds to wait for data
require_approval: "always" # Optional: see Approving tool calls
}
# Local server, over stdio
{
name: "server_name", # Optional: server identifier, defaults to the command
command: "mcp-server-files", # Required: executable to run
args: [ "--root", "/tmp" ], # Optional: arguments
env: { "TOKEN" => "secret" }, # Optional: environment
read_timeout: 10 # Optional: seconds to wait for an answer
}name: defaults to the URL host or command executable. Set it explicitly when multiple servers share a host.
read_timeout: sets how long ActiveAgent waits on a server it runs, in seconds (30 by default). It must be a positive, finite number; anything else raises ArgumentError when ActiveAgent connects to the server. What it bounds depends on the transport:
command:servers: the whole answer to each request — the handshake, the tool list, and each tool call. Notifications and pings the server sends meanwhile do not extend it. A server that misses it is stopped, and the generation fails withActiveAgent::Providers::MCPBridge::TimeoutError, which names the server and the request. The handshake also allows the 5 seconds themcpgem gives itsserver/discoverprobe, so a server that ignores the probe still has its fullread_timeout:to answer.url:servers: each wait for data from the server, so a streamed answer stays open for as long as it keeps sending events. Opening the connection is not covered; it keeps Net::HTTP's own timeout.
For HTTP servers, max_reconnection_wait: configures the transport's reconnection limit.
On a cache miss, ActiveAgent connects during prompt setup to list tools. On a cache hit, it connects only if the model calls a tool. Connections close when the generation ends, including on error.
Single Server
class DataAgent < ActiveAgent::Base
generate_with :anthropic, model: "claude-haiku-4-5"
def analyze
prompt(
"Analyze the latest data",
mcps: [ { name: "cloudflare-demo", url: "https://demo-day.mcp.cloudflare.com/sse" } ]
)
end
endMultiple Servers
class IntegratedAgent < ActiveAgent::Base
generate_with :openai, model: "gpt-4o"
def research
prompt(
"Research the latest AI developments",
mcps: [
{ name: "cloudflare", url: "https://demo-day.mcp.cloudflare.com/sse" },
{ name: "github", url: "https://api.githubcopilot.com/mcp/", authorization: ENV["GITHUB_MCP_TOKEN"] }
]
)
end
endWith Function Tools
class HybridAgent < ActiveAgent::Base
generate_with :openai, model: "gpt-4o"
def analyze_data
prompt(
"Calculate and fetch data",
tools: [ {
name: "calculate",
description: "Perform calculations",
parameters: {
type: "object",
properties: {
operation: { type: "string" },
a: { type: "number" },
b: { type: "number" }
}
}
} ],
mcps: [ { name: "data-service", url: "https://demo-day.mcp.cloudflare.com/sse" } ]
)
end
def calculate(operation:, a:, b:)
case operation
when "add" then a + b
when "subtract" then a - b
end
end
endOpenAI
OpenAI supports MCP via the Responses API with pre-built connectors and custom servers.
Pre-built Connectors
class FileAgent < ActiveAgent::Base
generate_with :openai, model: "gpt-4o"
def search_files
prompt(
input: "Find documents about Q4 revenue",
mcps: [ { name: "dropbox", url: "mcp://dropbox" } ] # Pre-built connector
)
end
endAvailable: Dropbox, Google Drive, GitHub, Slack, and more. See OpenAI's MCP docs for the full list.
Custom Servers
class CustomAgent < ActiveAgent::Base
generate_with :openai, model: "gpt-4o"
def custom_tools
prompt(
input: "Use custom tools",
mcps: [ { name: "github_copilot", url: "https://api.githubcopilot.com/mcp/", authorization: ENV["GITHUB_MCP_TOKEN"] } ]
)
end
endAnthropic
Anthropic supports MCP servers via the mcp_servers parameter (beta). Up to 20 servers per request.
class ClaudeAgent < ActiveAgent::Base
generate_with :anthropic, model: "claude-haiku-4-5"
def use_mcp
prompt(
message: "What tools are available?",
mcps: [ { name: "demo-server", url: "https://demo-day.mcp.cloudflare.com/sse" } ]
)
end
endSee Anthropic's MCP docs for details.
Who runs the server
With mcp_strategy: :auto, ActiveAgent connects to each declared server, lists the tools it offers, and routes calls back to the appropriate server, avoiding silent failures from providers like DeepSeek that ignore mcp_servers.
class ResearchAgent < ApplicationAgent
generate_with :deepseek, model: "deepseek-flash"
def research(topic)
prompt(
"Find and summarize recent news about #{topic}.",
mcps: [ { name: "firecrawl", url: "https://mcp.firecrawl.dev/YOUR_KEY/v2/mcp" } ]
)
end
endUse allowed_tools: to expose only the tools the agent needs; every exposed schema is sent with each model request.
Choosing the strategy
Use mcp_strategy: to choose where servers run:
| Strategy | Behavior |
|---|---|
:auto (default) | The provider serves what it can; ActiveAgent runs the rest. |
:client | ActiveAgent runs every declared server, including ones the provider could serve. |
:server | Every declared server must be served by the provider. Raises ArgumentError naming what the provider can serve if it cannot. |
class ResearchAgent < ApplicationAgent
generate_with :anthropic, model: "claude-haiku-4-5"
def research(topic)
prompt(
"Find and summarize recent news about #{topic}.",
# Run this one ourselves rather than handing it to Anthropic.
mcp_strategy: :client,
mcps: [ { name: "firecrawl", url: "https://mcp.firecrawl.dev/YOUR_KEY/v2/mcp" } ]
)
end
endA local (command:) server always runs client-side, so mcp_strategy: :server with one raises even on Anthropic or OpenAI Responses. So does a server whose calls may need approval; see Approving tool calls.
Requires the mcp gem
Add gem "mcp" to your Gemfile. It is loaded only when a client-side bridge is built, so it stays optional for applications that do not use mcps: against a client-side provider. Without it, the error names the gem to add.
TIP
Two tools sharing a name are refused rather than resolved by guessing, since the model has no way to say which it meant. Rename one, or restrict a server with allowed_tools:.
Because discovering tools means connecting to the servers, preview does not resolve mcps: — a preview must not perform I/O. It shows the agent's declared tools only.
Approving tool calls
A server's tool calls can wait for the user before they run. Set require_approval: on the declaration:
require_approval: | Calls that wait for approval |
|---|---|
"never" or absent | None |
"always" | Every tool the server offers |
{ always: [...] } | The tools listed |
{ never: [...] } | Every tool except the ones listed |
A list is an array of tool names or { tool_names: [...] }, the shape OpenAI's hosted MCP tool takes. A map with both keys asks for the tools under always and every tool not listed under never.
class FilesAgent < ApplicationAgent
generate_with :anthropic, model: "claude-sonnet-4-5"
def tidy
prompt(
"Remove the drafts older than a month",
mcps: [ { name: "files", url: "https://files.example.com/mcp", require_approval: { never: [ "list_files" ] } } ]
)
end
end
response = FilesAgent.tidy.generate_now
response.awaiting_input? # => true
response.input_requests.first.tool_name # => "delete_file"The approval is asked for in ActiveAgent's own tool loop, which never sees the calls of a server the provider runs itself. A declaration that sets require_approval to anything but "never" is therefore run client-side on every provider, Anthropic and OpenAI Responses included, and mcp_strategy: :server with one raises ArgumentError. A server whose require_approval is "never" or absent runs where mcp_strategy: puts it.
The requires_approval: prompt option names tools to approve whatever serves them, and covers MCP tools too. It can only gate calls ActiveAgent makes, so on Anthropic and OpenAI Responses a remote server that may offer a named tool runs client-side:
- a server with
allowed_tools:runs client-side when they include a named tool - a server without
allowed_tools:runs client-side when a named tool is not one of the prompt'stools:, because the server's tools are unknown until ActiveAgent connects to it
To keep a server with the provider, list its tools in allowed_tools: and leave the named tools out. mcp_strategy: :server with a server that may offer a named tool raises ArgumentError.
The call pauses the generation like any other input request: approving runs the tool on the server, and declining never calls it.
Pauses and client-side servers
A pause ends the generation, so the client-side connections close as they do after any generation, and a command: server's process stops. A local server loses whatever state it held in memory, such as a browser page or an open file.
On resume, ActiveAgent connects again and lists the tools, or takes the list from the tool cache. A paused call to a tool the server no longer lists gets { "error": "<tool> is no longer offered by its MCP server" } as its result, without a call to the server. While the tool cache still lists the tool, the call goes to the server, and the server's own error becomes the result.
What it costs
Two different costs, and only one of them can be cached.
Fetching a server's tool list takes a handshake and a tools/list round trip — about 1.1s in a hosted-server measurement. ActiveAgent caches schemas in process memory for five minutes by default:
ActiveAgent::Providers::MCPToolCache.configure(ttl: 300, max_entries: 100)Override the process-level cache setting for one generation with mcp_cache: false:
prompt(
"Inspect this site",
mcp_cache: false,
mcps: [ { name: "firecrawl", url: ENV.fetch("FIRECRAWL_MCP_URL") } ]
)Entries are isolated by endpoint, bearer credential, command/arguments/environment, and allowed_tools:. Only server tool schemas are cached — never prompts, results, or agent-declared tools. Credentials are hashed, not stored as cache keys.
A cache miss connects once to list tools. A cache hit avoids connecting unless the model calls a tool; command: servers start on first use in that generation.
The process-local cache holds no sockets or child processes. Clear it after a server update with MCPToolCache.clear!.
Replaying MCP traffic in tests
The cache changes request counts. Since MCP calls POST to one URL, URI-matched cassettes replay in order and become misaligned when a cached tools/list is skipped. Disable caching in cassette-backed tests and reset it between examples:
ActiveAgent::Providers::MCPToolCache.configure(enabled: false) # cassette-backed suites
ActiveAgent::Providers::MCPToolCache.reset! # or reset between examplesSending the tool list with each request cannot be cached away. Every API call is stateless and a model can only call a tool it was just told about, so the schemas go out on every turn of the tool loop and sit in the model's context. What you control is how much there is: a server offering 27 tools measured 14,659 input tokens per completion against 2,007 for one tool, and a tool loop pays that on each turn. allowed_tools: is the lever — restrict a server to what the agent actually calls.
Providers cache the repeated prefix on their side to soften this, but ActiveAgent does not currently mark Anthropic's cache breakpoint, so Anthropic bills the full list each turn. OpenAI and DeepSeek apply prefix caching automatically.
Native Formats
ActiveAgent converts the common format to provider-specific formats automatically. Use native formats only if needed for provider-specific features.
class OpenAINativeAgent < ActiveAgent::Base
generate_with :openai, model: "gpt-4o"
def native_format
prompt(
input: "What can you do?",
tools: [ {
type: "mcp",
server_label: "github",
server_url: "https://api.githubcopilot.com/mcp/",
authorization: ENV["GITHUB_MCP_TOKEN"]
} ]
)
end
endclass AnthropicNativeAgent < ActiveAgent::Base
generate_with :anthropic, model: "claude-haiku-4-5"
def native_format
prompt(
message: "What can you do?",
mcp_servers: [ {
type: "url",
name: "cloudflare",
url: "https://demo-day.mcp.cloudflare.com/sse"
} ]
)
end
endTroubleshooting
Server not responding: Verify the URL is correct and accessible from your environment.
Authorization failures: Check token validity, permissions, and expiration.
Tools not available: Ensure the server implements MCP correctly and returns valid tool definitions.
Related
- Tools - Function tools and tool choice
- OpenAI Provider - OpenAI-specific features
- Anthropic Provider - Anthropic-specific features