MCP Integration¶
RobotLab supports the Model Context Protocol (MCP) for connecting to external tool servers.
What is MCP?¶
MCP is a protocol that allows LLM applications to connect to external servers that provide tools, resources, and context. This enables:
- Reusable tool servers across applications
- Separation of tool logic from AI logic
- Dynamic tool discovery
Configuring MCP Servers¶
At Robot Level¶
Use the mcp: parameter on RobotLab.build to connect a robot to MCP servers:
robot = RobotLab.build(
name: "coder",
template: :developer,
mcp: [
{
name: "filesystem",
transport: {
type: "stdio",
command: "mcp-server-filesystem",
args: ["--root", "/home/user/projects"]
}
},
{
name: "github",
transport: {
type: "stdio",
command: "mcp-server-github",
env: { "GITHUB_TOKEN" => ENV["GITHUB_TOKEN"] }
}
}
]
)
In Template Front Matter¶
MCP servers can be declared directly in a template's YAML front matter, making the template fully self-contained:
---
description: GitHub assistant with MCP tool access
mcp:
- name: github
transport:
type: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
---
You are a helpful GitHub assistant with access to GitHub tools via MCP.
# MCP config comes from the template — no mcp: parameter needed
robot = RobotLab.build(template: :github_assistant)
Constructor mcp: overrides frontmatter mcp: when provided.
[!CAUTION]
transport:must be a nested mapping, exactly as above. The flat form —transport: stdiowith siblingcommand:/args:keys — does not raise:Servercallstransform_keyson the transport value, a String raisesNoMethodError: undefined method 'transform_keys' for an instance of String, and that exception is caught by MCP setup's rescue. The robot builds and runs with zero tools from that server. It is not silent, though: the failure is logged atWARNthroughRobotLab.config.logger($stdoutby default) and the server name is recorded inrobot.failed_mcp_server_names.
Hierarchical Configuration¶
The mcp: parameter supports three modes:
| Value | Behavior |
|---|---|
:none |
No MCP servers (default) |
:inherit |
Inherit from network or global config |
[...] |
Explicit array of server configurations |
# Inherit from network/config
robot = RobotLab.build(
name: "reader",
system_prompt: "You help read files.",
mcp: :inherit
)
# Disable MCP explicitly
robot = RobotLab.build(
name: "calculator",
system_prompt: "You do math.",
mcp: :none
)
Resolution Order¶
MCP configuration resolves through a hierarchy: runtime > robot build > network > global config. Each level can override the previous:
Global (RobotLab.config.mcp)
-> Network (network config: / task config:)
-> Robot (mcp: :inherit | :none | [...])
-> Runtime (robot.run("msg", mcp: [...]), and task mcp: [...])
[!NOTE] A task's
mcp:/tools:are not a separate network tier —Taskputs them into the run params, so they arrive as the runtime value for that robot'srun(). Only aconfig:(on the network or on the task) acts as the parent level that:inheritresolves against.
Timeout Configuration¶
Every server config accepts a timeout:. The default is 15 seconds
(RobotLab::MCP::Server::DEFAULT_TIMEOUT). Set it at the server level:
robot = RobotLab.build(
name: "patient_bot",
system_prompt: "You help with slow operations.",
mcp: [
{
name: "heavy_server",
transport: { type: "stdio", command: "heavy-mcp-server" },
timeout: 60 # seconds
}
]
)
Values >= 1000 are auto-converted from milliseconds to seconds. The minimum timeout is 1 second:
| Given | Stored |
|---|---|
nil |
15 (the default) |
30 |
30.0 |
5000 |
5.0 (read as milliseconds) |
0.5 |
1 (floored to the 1-second minimum) |
[!WARNING] Only the Stdio transport actually enforces the timeout. SSE, WebSocket, and StreamableHTTP store the value and never reference it — a remote MCP server that stops responding will hang the call indefinitely regardless of what you set here. Apply your own timeout around remote-transport calls if you need one.
Transport Types¶
Valid type: values are exactly: stdio, sse, ws, websocket,
streamable-http, http (RobotLab::MCP::Server::VALID_TRANSPORT_TYPES).
An invalid type raises ArgumentError: Invalid transport type: <type>. Must be
one of: stdio, sse, ws, websocket, streamable-http, http — but only if you
construct the server or client yourself. Through the robot (mcp: on
RobotLab.build) the error is caught by the same rescue that handles any other
connect failure: it is logged at WARN and the server name is recorded in
robot.failed_mcp_server_names, and the robot carries on with zero tools from
that server. See Connection Errors.
bad = { name: "oops", transport: { type: "streamable_http", url: "https://x" } }
RobotLab::MCP::Server.new(**bad) # => raises ArgumentError
robot = RobotLab.build(name: "r", system_prompt: "...", mcp: [bad])
robot.run("hi", mcp: :inherit, tools: :inherit)
# logs: WARN Robot 'r' error connecting to MCP server 'oops': Invalid transport type: ...
robot.failed_mcp_server_names # => ["oops"]
Stdio Transport¶
Communicate via stdin/stdout with a subprocess:
{
name: "server_name",
transport: {
type: "stdio",
command: "mcp-server-command",
args: ["--option", "value"],
env: { "API_KEY" => ENV["API_KEY"] }
}
}
WebSocket Transport¶
Connect via WebSocket:
Dependency Required
WebSocket transport requires the async-websocket gem. (It also reaches for
Async::HTTP::Endpoint without requiring it, so async-http must be loaded
as well.)
SSE Transport¶
Server-Sent Events transport:
Dependency Required
SSE transport requires the async-http gem.
HTTP Transport¶
Streamable HTTP transport with session support:
{
name: "http_server",
transport: {
type: "streamable-http", # or "http" — NOT "streamable_http"
url: "https://api.example.com/mcp",
session_id: "optional_session_id",
auth_provider: -> { "Bearer #{fetch_token}" }
}
}
Dependency Required
Streamable HTTP transport requires the async-http gem.
[!CAUTION] The type is spelled with a hyphen.
type: "streamable_http"(underscore) raisesArgumentError: Invalid transport type: streamable_http.[!WARNING] The remote transports connect inside an un-awaited
Asyncblock and set their connected flag before the MCP initialize handshake.client.connected?therefore returnstrueeven when the host is unreachable — do not treat it as proof the server answered. Stdio is the only transport that blocks on the handshake and reports a connect failure synchronously.
Using MCP Tools¶
Once configured, MCP tools are discovered on connect and made available to the robot. Connecting is not automatic — see the warning below:
robot = RobotLab.build(
name: "helper",
system_prompt: <<~PROMPT,
You can help users with GitHub tasks.
Use available tools to search repositories, create issues, etc.
PROMPT
mcp: [
{ name: "github", transport: { type: "stdio", command: "mcp-server-github" } }
]
)
# mcp: :inherit connects the servers; tools: :inherit sends their tools to the LLM.
result = robot.run("Find repositories about machine learning", mcp: :inherit, tools: :inherit)
puts result.last_text_content
[!CAUTION] A plain
robot.run(message)neither connects MCP servers nor sends any tools. Both themcp:andtools:parameters ofrundefault to:none, which means "zero this turn" — the build-timemcp:list is simply not consulted. You need both flags:
Filtering MCP Tools¶
Use the tools: parameter to restrict which tools (including MCP-discovered
tools) reach the LLM. It is a name allowlist, and it must be supplied at the
level that actually runs — the run() call (or the network task, which
forwards to run()):
robot = RobotLab.build(
name: "reader",
system_prompt: "You help read and search files.",
mcp: [
{ name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } }
]
)
robot.run("Summarise the README",
mcp: :inherit,
tools: %w[read_file search_files list_directory]) # only these
[!WARNING] A build-time
tools: %w[...]allowlist has no effect on its own, because the runtime default of:nonesends zero tools regardless. And for a standalone robot, do not writetools: :inheritat build time as a workaround: there the parent level is the global:none, so it produces an allowlist of["none"]that matches nothing. Leave build-timetools:unset and pass the filter at run time.Inside a network whose
config:suppliestools:/mcp:, this reverses: the parent level is the network's list, and build-time:inheritis what makes the robot pick it up. See Network-Wide Tool and MCP Defaults.
MCP in Networks¶
When running robots in a network, use per-task MCP configuration. Remember that
a task's mcp:/tools: become that robot's runtime values, so tools:
must be set too or the MCP tools will not be sent:
network = RobotLab.create_network(name: "dev_pipeline") do
task :planner, planner_robot, depends_on: :none
task :coder, coder_robot,
mcp: [
{ name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } }
],
tools: :inherit,
depends_on: [:planner]
task :reviewer, reviewer_robot, depends_on: [:coder]
end
Common MCP Servers¶
Filesystem¶
{
name: "filesystem",
transport: {
type: "stdio",
command: "mcp-server-filesystem",
args: ["--root", "/path/to/files"]
}
}
Tools: read_file, write_file, list_directory, search_files
GitHub¶
{
name: "github",
transport: {
type: "stdio",
command: "mcp-server-github",
env: { "GITHUB_TOKEN" => ENV["GITHUB_TOKEN"] }
}
}
Tools: search_repositories, create_issue, get_file_contents, etc.
Database¶
{
name: "postgres",
transport: {
type: "stdio",
command: "mcp-server-postgres",
env: { "DATABASE_URL" => ENV["DATABASE_URL"] }
}
}
Tools: query, list_tables, describe_table
MCP Server and Client Objects¶
For programmatic access, you can work with MCP objects directly:
# Server configuration
server = RobotLab::MCP::Server.new(
name: "my_server",
transport: {
type: "stdio",
command: "my-mcp-server"
}
)
# Client connection
client = RobotLab::MCP::Client.new(server)
client.connect
client.connected? # => true
client.list_tools # => Array of tool definitions
client.call_tool("search", { query: "ruby" })
client.list_resources # => Array of resource definitions
client.disconnect
[!NOTE]
MCP::Client#initialize(server_or_config, poller: nil)takes its server as a positional argument — either aServerinstance or a config Hash.Client.new(name: "x", transport: {...})raisesArgumentError: wrong number of arguments (given 0, expected 1); wrap the hash in braces:Client.new({ name: "x", transport: {...} }).
Connection Multiplexing¶
When a robot connects to several local (stdio) MCP servers, each client normally blocks independently while waiting for a response. MCP::ConnectionPoller replaces this with a single IO.select call across all registered stdout file descriptors, dispatching each response to the pending request for that client.
This is primarily useful in networks where many robots each have multiple stdio MCP servers. Async-based transports (SSE, WebSocket, StreamableHTTP) are unaffected — they already use the Async fiber scheduler.
# Create a shared poller
poller = RobotLab::MCP::ConnectionPoller.new.start
# Pass the poller when building clients
client1 = RobotLab::MCP::Client.new(
{ name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } },
poller: poller
)
client2 = RobotLab::MCP::Client.new(
{ name: "github", transport: { type: "stdio", command: "mcp-server-github" } },
poller: poller
)
client1.connect # registers with poller
client2.connect # registers with poller
# Both clients share the IO.select loop
client1.list_tools
client2.list_tools
poller.stop
Without a shared poller each client uses its own blocking Timeout.timeout call. With a poller, responses from any registered server wake the poller's select loop, which dispatches to the right waiting thread via a Thread::Queue.
Note
Only stdio clients are registered with the poller. SSE, WebSocket, and StreamableHTTP clients passed a poller: argument ignore it silently.
Server Discovery¶
When a robot has many MCP servers configured, connecting to all of them upfront is wasteful — most servers will be irrelevant for any given user message. Server Discovery uses TF cosine similarity to select only the semantically relevant servers before the first ensure_mcp_clients call.
Enabling Discovery¶
Add description: to each server config and set mcp_discovery: true on the robot:
robot = RobotLab.build(
name: "assistant",
system_prompt: "You are a helpful assistant.",
mcp_discovery: true,
mcp: [
{
name: "filesystem",
description: "Read, write, and search local files and directories",
transport: { type: "stdio", command: "mcp-server-filesystem" }
},
{
name: "github",
description: "GitHub repos, issues, pull requests, code search",
transport: { type: "stdio", command: "mcp-server-github" }
},
{
name: "brew",
description: "Install, update, and manage macOS packages via Homebrew",
transport: { type: "stdio", command: "mcp-server-brew" }
}
]
)
# Discovery connects only :brew for this message — filesystem and github are skipped.
# mcp: :inherit is required; a plain run() resolves the MCP list to empty and
# discovery never runs.
robot.run("install imagemagick", mcp: :inherit, tools: :inherit)
How It Works¶
MCP::ServerDiscovery.select(query, from:, threshold:) computes TF cosine similarity between the user's query and each server's topic text (name + description). Servers scoring at or above DEFAULT_THRESHOLD (0.05) are returned; the rest are excluded.
The threshold is intentionally low — server descriptions are short, so raw cosine scores are naturally small even for on-topic queries.
Discovery only applies on the first MCP-resolving run() call (before the robot is marked MCP-initialized). Once a set of servers is connected they remain connected for the robot's lifetime, preserving tool continuity across a conversation.
Fallback Behaviour¶
All servers are returned unchanged when any of the following apply:
| Condition | Reason |
|---|---|
No server has a description field |
Nothing to score against |
classifier gem unavailable |
Raises DependencyError, caught internally |
| Query is blank or nil | Nothing to compare |
| No server scores ≥ threshold | Rather fall back than leave the robot with no tools |
Using the API Directly¶
servers = [
{ name: "filesystem", description: "Read and write files",
transport: { type: "stdio", command: "mcp-server-fs" } },
{ name: "github", description: "GitHub repos and PRs",
transport: { type: "stdio", command: "mcp-server-github" } }
]
relevant = RobotLab::MCP::ServerDiscovery.select(
"search github repos",
from: servers,
threshold: 0.05 # optional, default
)
# => [{ name: "github", ... }]
[!IMPORTANT] Scoring is lexical, not semantic — it compares word stems against each server's
description. A query that happens to share no stems with any description scores 0.0 everywhere, trips the "nothing above threshold" fallback in the table above, and gets all servers back rather than none. With the two servers above:select("search github repos", from: servers) # => ["github"] select("read and write files", from: servers) # => ["filesystem"] select("list open pull requests", from: servers) # => ["filesystem", "github"] <- fallbackThe last one selects everything because the description says "PRs", not "pull requests". Write descriptions using the words your prompts will actually use.
Connection Resilience¶
Eager Connection¶
connect_mcp! connects the robot's configured servers immediately, without
waiting for a run() that passes mcp: :inherit:
robot = RobotLab.build(
name: "assistant",
system_prompt: "You help with tasks.",
mcp: [
{ name: "github", transport: { type: "stdio", command: "mcp-server-github" } },
{ name: "filesystem", transport: { type: "stdio", command: "mcp-server-fs" } }
]
)
robot.connect_mcp!
# Check which servers failed
if robot.failed_mcp_server_names.any?
puts "Failed to connect: #{robot.failed_mcp_server_names.join(', ')}"
end
[!NOTE]
connect_mcp!only opens the connections. A later plainrobot.run(message)still sends the LLM zero tools, becauserun'stools:defaults to:none. Eager connection and tool visibility are separate switches.
Automatic Retry¶
Failed MCP servers are retried on subsequent run() calls that resolve to a
non-empty MCP list — i.e. runs that pass mcp: :inherit (or an explicit
array). If a server was down when the robot first connected, it is retried
transparently:
robot.run("First message", mcp: :inherit, tools: :inherit) # github connects, filesystem fails
# ... filesystem comes back up ...
robot.run("Second message", mcp: :inherit, tools: :inherit) # filesystem retried and connects
Injecting External MCP Clients¶
Host applications that manage MCP connections externally can inject pre-connected clients into a robot:
This skips the normal connection process and marks the robot as MCP-initialized.
Error Handling¶
Connection Errors¶
MCP connection failures are not raised. They are logged as warnings and recorded on the robot; the run continues without that server's tools, and one failing server does not prevent the others from connecting. Inspect the result rather than rescuing:
result = robot.run("Search for repos", mcp: :inherit, tools: :inherit)
if robot.failed_mcp_server_names.any?
warn "MCP servers unavailable: #{robot.failed_mcp_server_names.join(', ')}"
end
Timeout Errors¶
Stdio transports wrap all blocking I/O with a configurable timeout. On expiry
the transport raises MCPError. Note that the two messages differ, and neither
uses the server name you configured — the handshake message names the
command, and the per-request message names nothing at all:
# Handshake timed out during connect:
# RobotLab::MCPError: MCP server 'heavy-mcp-server' did not respond within 15s
# A later request timed out:
# RobotLab::MCPError: MCP server did not respond within 15s
[!IMPORTANT]
MCP::Client#connectrescues everyStandardError, logs"MCP connection failed for <name>: ..."at:warn, and returnsselfwithconnected?false. So a connect-time timeout never reaches yourbegin/rescue— checkclient.connected?(orrobot.failed_mcp_server_names) instead. Per-request calls such aslist_toolsandcall_tooldo propagateMCPError.
SSE, WebSocket, and StreamableHTTP raise no timeout error at all — see the warning under Timeout Configuration.
Disconnecting¶
Robots can be manually disconnected from MCP servers:
Patterns¶
Development vs Production¶
mcp_config = if Rails.env.development?
[{ name: "local_fs", transport: { type: "stdio", command: "mcp-fs", args: ["--root", "."] } }]
else
[{ name: "s3", transport: { type: "stdio", command: "mcp-s3" } }]
end
robot = RobotLab.build(
name: "file_handler",
system_prompt: "You manage files.",
mcp: mcp_config
)
Dynamic Server Selection¶
def mcp_servers_for_user(user)
servers = []
servers << github_server if user.github_connected?
servers << slack_server if user.slack_connected?
servers
end
robot = RobotLab.build(
name: "assistant",
system_prompt: "You help the user with connected services.",
mcp: mcp_servers_for_user(current_user)
)
Best Practices¶
1. Use Environment Variables for Credentials¶
{
name: "github",
transport: {
type: "stdio",
command: "mcp-server-github",
env: {
"GITHUB_TOKEN" => ENV["GITHUB_TOKEN"],
"GITHUB_ORG" => ENV["GITHUB_ORG"]
}
}
}
2. Limit Tool Access¶
Restrict which MCP tools reach the LLM with a tools: allowlist on the run:
robot = RobotLab.build(
name: "reader",
system_prompt: "You read and search files.",
mcp: [{ name: "fs", transport: { type: "stdio", command: "mcp-fs" } }]
)
robot.run("Find the config file",
mcp: :inherit,
tools: %w[read_file search_files]) # No write access
3. Use Appropriate Transports¶
| Transport | Best For |
|---|---|
stdio |
Local servers, CLI tools. The only transport that enforces timeout: and reports connect failures synchronously. |
websocket (or ws) |
Persistent connections, bidirectional |
sse |
Server push, event streams |
streamable-http (or http) |
Remote APIs, session-based |
Next Steps¶
- Using Tools - Local tool patterns
- Creating Networks - Network configuration
- API Reference: MCP - Complete MCP API