MCP Server¶
Connecting robots to Model Context Protocol servers for external tool access.
Overview¶
This example demonstrates how to connect robots to external MCP servers. MCP servers expose tools that robots can discover and invoke automatically. RobotLab supports stdio, HTTP, WebSocket, and SSE transports.
The runnable version of everything below is
examples/04_mcp.rb
(direct MCP::Client usage in Part 1, robot integration in Part 2).
[!WARNING] Two things are required to actually use MCP tools in a run.
Robot#rundefaults tomcp: :none, tools: :none, so build-timemcp:servers are never connected by a plainrobot.run("..."). Pass both:
mcp: :inherittriggers the connection attempt;tools: :inheritis what sends the discovered tools to the LLM.[!WARNING]
transport:must be a nested hash. A flattransport: stdiowith siblingcommand:/args:keys raisesNoMethodError: undefined method 'transform_keys' for an instance of Stringinternally — and that error is swallowed, so the robot silently builds with zero MCP tools. Connection failures are logged and recorded inrobot.failed_mcp_server_names; they are never raised.
Using MCP with a Robot¶
The primary pattern is to pass MCP server configurations via mcp: or mcp_servers: when building a robot:
#!/usr/bin/env ruby
require "bundler/setup"
require "robot_lab"
# MCP server configuration (stdio transport)
github_server = {
name: "github",
transport: {
type: "stdio",
command: "github-mcp-server",
args: ["stdio"],
env: {
"GITHUB_PERSONAL_ACCESS_TOKEN" => ENV.fetch("GITHUB_PERSONAL_ACCESS_TOKEN", "")
}
}
}
# Create a robot with MCP server integration
# Tools are automatically discovered when the robot connects
robot = RobotLab.build(
name: "github_assistant",
system_prompt: <<~PROMPT,
You are a GitHub assistant with access to repository tools.
Help users search repos, manage issues, and explore code.
PROMPT
mcp: [github_server],
model: "claude-sonnet-4"
)
# MCP clients are created lazily. connect_mcp! forces the connection now so the
# counts below are meaningful -- without it both lines print empty results.
robot.connect_mcp!
puts "MCP Servers: #{robot.mcp_clients.keys.join(", ")}"
puts "MCP Tools: #{robot.mcp_tools.size} discovered"
puts "Failed servers: #{robot.failed_mcp_server_names.join(", ")}" if robot.failed_mcp_server_names.any?
# Run the robot -- both kwargs are required for it to reach the MCP tools
result = robot.run(
"What are the top 3 most starred Ruby web frameworks on GitHub?",
mcp: :inherit,
tools: :inherit
)
puts result.last_text_content
# Confirm which tools were sent this turn
puts "Tools sent: #{robot.chat.tools.size}"
# Always disconnect MCP clients when done
robot.disconnect
[!NOTE]
result.tool_callsis effectively always empty — it reads the final assistant message, which contains only text once ruby_llm's tool loop has finished. Use theon_tool_call:callback or the Hook system to observe MCP tool invocations.
Direct MCP Client Usage¶
You can also use the MCP client directly without a robot:
require "robot_lab"
# Create and connect MCP client
github_server = {
name: "github",
transport: {
type: "stdio",
command: "github-mcp-server",
args: ["stdio"],
env: { "GITHUB_PERSONAL_ACCESS_TOKEN" => ENV["GITHUB_PERSONAL_ACCESS_TOKEN"] }
}
}
# NOTE: the config is a single POSITIONAL argument.
# MCP::Client.new(name: ..., transport: ...) raises ArgumentError.
client = RobotLab::MCP::Client.new(github_server)
client.connect
if client.connected?
# List available tools
tools = client.list_tools
tools.each do |tool|
puts "#{tool[:name]}: #{tool[:description]}"
end
# Call a tool directly
result = client.call_tool("search_repositories", {
query: "language:ruby stars:>1000",
per_page: 5
})
puts JSON.pretty_generate(result)
client.disconnect
end
Multiple MCP Servers¶
A robot can connect to multiple MCP servers simultaneously:
filesystem_server = {
name: "filesystem",
transport: {
type: "stdio",
command: "npx",
args: ["@modelcontextprotocol/server-filesystem", "/data"]
}
}
github_server = {
name: "github",
transport: {
type: "stdio",
command: "github-mcp-server",
args: ["stdio"],
env: { "GITHUB_PERSONAL_ACCESS_TOKEN" => ENV["GITHUB_PERSONAL_ACCESS_TOKEN"] }
}
}
robot = RobotLab.build(
name: "developer",
system_prompt: "You help with coding tasks using GitHub and the filesystem.",
mcp: [filesystem_server, github_server],
model: "claude-sonnet-4"
)
result = robot.run(
"Search for Ruby repos with CI configs and list their workflow files",
mcp: :inherit,
tools: :inherit
)
puts result.last_text_content
robot.disconnect
MCP in Networks¶
mcp and tools are the only two fields a network passes down to its member
robots. Declare them per task — the task DSL is what reliably reaches the
robot's run call.
# Create robots -- leave mcp:/tools: unset in the constructor
data_analyst = RobotLab.build(
name: "data_analyst",
system_prompt: "You analyze data."
)
file_manager = RobotLab.build(
name: "file_manager",
system_prompt: "You manage files."
)
# Per-task MCP + tools configuration
network = RobotLab.create_network(name: "support_with_mcp") do
task :data_analyst, data_analyst,
mcp: [github_server],
tools: :inherit, # send everything discovered
depends_on: :none
task :file_manager, file_manager,
mcp: [filesystem_server],
tools: %w[read_file list_directory], # allowlist only these tool names
depends_on: :optional
end
result = network.run(message: "Analyze the project structure")
[!WARNING] Build-time
:inheritcuts both ways, so scope it deliberately.For a standalone robot it is a trap:
:inheritresolves against the global level (:none), yielding an allowlist of["none"]that matches nothing, so the robot sends no tools no matter what you pass at run time.Inside a network, build-time
:inheritis exactly how a robot opts into the network'sconfig:list — the parent is resolved at run time fromnetwork_config. Verified with a networkconfig:oftools: %w[ReadFile]on a robot holdingReadFileandListDir:
Robot constructor Task line Tools sent tools: :inherittools: :inherit[:read_file]— network allowlist applied(unset) tools: :inherit[:read_file, :list_dir]— allowlist ignoredA network-level
config:also propagates onlymcpandtools. LLM fields (model,temperature,max_tokens, ...) and callbacks (on_content) are read from each robot's own config at construction time and are never inherited from the network.
Transport Types¶
The type: field must be one of stdio, sse, ws, websocket,
streamable-http, or http. Anything else raises ArgumentError — note that
streamable_http with an underscore is not valid.
timeout: sits alongside transport: on the server config and defaults to 15
seconds. It is normalized: nil becomes 15, any value of 1000 or more is treated
as milliseconds (5000 → 5.0s), and anything under 1 second is floored to 1.
[!NOTE] Only the stdio transport actually enforces
timeout. The SSE, WebSocket, and StreamableHTTP transports store the value and never reference it.
HTTP Transport¶
Connect to remote MCP servers over HTTP. Requires the async-http gem.
robot = RobotLab.build(
name: "remote_assistant",
system_prompt: "You have access to remote tools.",
mcp: [
{
name: "remote_api",
timeout: 30,
transport: {
type: "http",
url: "https://mcp.example.com/mcp",
# Auth is supplied by a callable, not a headers hash
auth_provider: -> { "Bearer #{ENV['MCP_TOKEN']}" }
}
}
]
)
result = robot.run("Use the remote tools to check system status", mcp: :inherit, tools: :inherit)
robot.disconnect
[!WARNING] A
headers:key in a streamable-http transport config is silently discarded. The transport builds its own header hash on every request and only merges inAuthorizationwhenauth_provideris set. Useauth_provider:(a proc returning the full header value) for authentication.[!WARNING] The HTTP and SSE transports set
@connected = truebefore the MCP initialization handshake completes, soconnected?returnstrueeven against an unreachable host. Treat it as "a connect was attempted", not "the server answered", and checkrobot.failed_mcp_server_namesas well.
WebSocket Transport¶
For real-time bidirectional communication. Requires the async-websocket gem.
robot = RobotLab.build(
name: "realtime_assistant",
system_prompt: "You monitor real-time events.",
mcp: [
{
name: "realtime",
transport: {
type: "websocket",
url: "ws://localhost:8765"
}
}
]
)
result = robot.run("Subscribe to the events channel", mcp: :inherit, tools: :inherit)
robot.disconnect
[!CAUTION] The WebSocket transport currently raises
NameErroronAsync::HTTP::Endpointinside an un-awaitedAsyncblock. It is not usable as shipped — prefer stdio or HTTP.
SSE Transport¶
Server-Sent Events transport. Requires the async-http gem.
robot = RobotLab.build(
name: "sse_assistant",
system_prompt: "You have access to streaming tools.",
mcp: [
{
name: "streaming_api",
transport: {
type: "sse",
url: "https://api.example.com/sse"
}
}
]
)
result = robot.run("Stream the latest metrics", mcp: :inherit, tools: :inherit)
robot.disconnect
Runtime MCP Selection¶
mcp: and tools: on robot.run decide, per call, what the LLM sees:
robot = RobotLab.build(
name: "flexible_bot",
system_prompt: "You use available tools.",
mcp: [github_server]
)
# Connect the configured MCP servers and send their tools
result = robot.run("Search for Ruby repos", mcp: :inherit, tools: :inherit)
# The DEFAULT: no MCP connection, no tools sent. Writing these out is
# redundant -- omitting both kwargs does exactly the same thing.
result = robot.run("Just answer from your knowledge")
# Connect MCP but send only two named tools
result = robot.run(
"List the open issues",
mcp: :inherit,
tools: %w[list_issues get_issue]
)
robot.disconnect
[!NOTE]
tools:is a name allowlist across every available tool — local and MCP alike.tools: :nonemeans "zero tools of any kind", not "MCP only"; there is no switch that selects local tools versus MCP tools.
Running¶
# Set API keys
export ANTHROPIC_API_KEY="your-key"
export GITHUB_PERSONAL_ACCESS_TOKEN="your-token"
# Install MCP server (example: GitHub)
brew install github-mcp-server
# Direct MCP::Client usage + robot integration
ruby examples/04_mcp.rb
# Semantic MCP server selection (no LLM calls)
ruby examples/28_mcp_discovery.rb
Key Concepts¶
- MCP Configuration: Pass server configs via
mcp:/mcp_servers:onRobotLab.buildorRobot.new.transport:must be a nested hash - Nothing is sent by default:
rundefaults tomcp: :none, tools: :none. Passmcp: :inherit, tools: :inheritto connect and send - Lazy connection: MCP clients are created on the first qualifying
run. Callrobot.connect_mcp!to connect eagerly — before that,mcp_clientsandmcp_toolsare empty - Failures are silent: connection errors are logged and recorded in
robot.failed_mcp_server_names, never raised - Transport Types:
stdio,sse,ws,websocket,streamable-http,http(streamable_httpis invalid) - Tool Filtering:
tools:is an allowlist over local and MCP tools combined; entries must match how each tool was attached (class-attached →"ReadFile", instance-attached →"read_file") - Networks: an explicit list on the
taskline applies directly; to inherit the network'sconfig:list instead, build the robot withtools: :inheritand passtools: :inheriton the task - Cleanup: Always call
robot.disconnectwhen done to release MCP connections