The ClaudeAgent Ruby SDK lets you define MCP (Model Context Protocol) tool servers that run in-process -- in the same Ruby process as your application. Unlike external MCP servers that launch as subprocesses communicating over stdio, SDK servers handle tool calls directly via method dispatch. This means faster execution, shared state with your application, and straightforward debugging.
The most concise way to define an MCP server is the block DSL. Pass a block to Server.new and call tool to register each tool inline:
server = ClaudeAgent::MCP::Server.new(name: "calculator") do |s|
s.tool("add", "Add two numbers", { a: :number, b: :number }) do |args|
(args[:a] + args[:b]).to_s
end
s.tool("multiply", "Multiply two numbers", { a: :number, b: :number }) do |args|
(args[:a] * args[:b]).to_s
end
endEach call to s.tool(name, description, schema, &handler) creates a ClaudeAgent::MCP::Tool and registers it on the server. The handler block receives a symbol-keyed Hash of the arguments Claude provides.
Create Tool objects first, then pass them to the server constructor:
add_tool = ClaudeAgent::MCP::Tool.new(
name: "add",
description: "Add two numbers",
schema: { a: Float, b: Float }
) { |args| (args[:a] + args[:b]).to_s }
subtract_tool = ClaudeAgent::MCP::Tool.new(
name: "subtract",
description: "Subtract two numbers",
schema: { a: Float, b: Float }
) { |args| (args[:a] - args[:b]).to_s }
server = ClaudeAgent::MCP::Server.new(
name: "calculator",
tools: [add_tool, subtract_tool]
)You can also add and remove tools after construction:
server.add_tool(new_tool)
server.remove_tool("old_tool")ClaudeAgent::MCP provides module-level shortcuts for creating tools and servers without fully qualifying the class names:
tool = ClaudeAgent::MCP.tool("greet", "Greet someone", { name: String }) do |args|
"Hello, #{args[:name]}!"
end
server = ClaudeAgent::MCP.create_server(name: "greeter", tools: [tool])The schema defines the JSON Schema for the tool's input parameters. Three formats are supported.
Pass a Hash mapping parameter names to Ruby classes. All parameters are marked as required:
schema: { name: String, age: Integer, score: Float }| Ruby Class | JSON Schema type |
|---|---|
String |
string |
Integer |
integer |
Float, Numeric |
number |
TrueClass, FalseClass |
boolean |
Array |
array |
Hash |
object |
Use symbols instead of classes for a more compact definition:
schema: { name: :string, count: :integer, ratio: :number, active: :boolean }| Symbol | JSON Schema type |
|---|---|
:string, :str |
string |
:integer, :int |
integer |
:number, :float, :numeric |
number |
:boolean, :bool |
boolean |
:array |
array |
:object, :hash |
object |
For full control (enums, optional fields, nested objects), pass a standard JSON Schema Hash directly. The SDK detects raw schemas by the presence of a type or properties key and passes them through unchanged:
schema: {
type: "object",
properties: {
operation: { type: "string", enum: ["add", "subtract", "multiply"] },
a: { type: "number" },
b: { type: "number" }
},
required: ["operation", "a", "b"]
}MCP tool annotations provide hints to the model about tool behavior. Pass an annotations Hash when creating a tool:
server = ClaudeAgent::MCP::Server.new(name: "files") do |s|
s.tool("read_file", "Read a file", { path: :string },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
title: "Read File"
}
) do |args|
File.read(args[:path])
end
end| Annotation | Type | Description |
|---|---|---|
readOnlyHint |
Boolean | Tool does not modify state |
destructiveHint |
Boolean | Tool may perform destructive operations |
idempotentHint |
Boolean | Repeated calls with same args produce same result |
openWorldHint |
Boolean | Tool interacts with external systems |
title |
String | Human-readable display name |
Annotations are omitted from the MCP definition when nil or empty.
The handler block's return value is automatically formatted into the MCP response structure. Several return types are supported:
String -- wrapped in a text content block:
s.tool("greet", "Greet", { name: :string }) do |args|
"Hello, #{args[:name]}!"
end
# => { content: [{ type: "text", text: "Hello, World!" }], isError: false }Numeric or other non-String -- converted via to_s and wrapped in a text content block:
s.tool("add", "Add", { a: :number, b: :number }) do |args|
args[:a] + args[:b]
end
# => { content: [{ type: "text", text: "7.5" }], isError: false }Hash with :content key -- used as-is, letting you return custom content blocks:
s.tool("image", "Generate image", {}) do |args|
{ content: [{ type: "image", data: base64_data, mimeType: "image/png" }] }
endArray -- treated as a pre-built content block array:
s.tool("multi", "Multiple outputs", {}) do |args|
[
{ type: "text", text: "Part 1" },
{ type: "text", text: "Part 2" }
]
endExceptions -- if the handler raises, the error is caught and returned as an error response:
s.tool("divide", "Divide", { a: :number, b: :number }) do |args|
raise "Division by zero" if args[:b] == 0
(args[:a] / args[:b]).to_s
end
# When b=0: { content: [{ type: "text", text: "Error: Division by zero" }], isError: true }To attach an MCP server to a query, pass it via the mcp_servers option. Each server entry is keyed by name and must include type: "sdk" and instance: pointing to the server object:
server = ClaudeAgent::MCP::Server.new(name: "calculator") do |s|
s.tool("add", "Add two numbers", { a: :number, b: :number }) do |args|
(args[:a] + args[:b]).to_s
end
end
options = ClaudeAgent::Options.new(
mcp_servers: {
"calculator" => { type: "sdk", instance: server }
}
)
turn = ClaudeAgent.ask("What is 2 + 3?", options: options)The server also provides a to_config shortcut that builds the config Hash for you:
options = ClaudeAgent::Options.new(
mcp_servers: {
"calculator" => server.to_config
}
)Register an MCP server globally so it is available to all queries without passing it in every Options:
server = ClaudeAgent::MCP::Server.new(name: "calculator") do |s|
s.tool("add", "Add two numbers", { a: :number, b: :number }) do |args|
(args[:a] + args[:b]).to_s
end
end
ClaudeAgent.register_mcp_server(server)
# Now every query can use the calculator tools
turn = ClaudeAgent.ask("What is 2 + 3?")Globally registered servers are stored in ClaudeAgent.config.default_mcp_servers and merged into the effective Options for each request.
In addition to in-process SDK servers, you can configure external MCP servers that run as subprocesses. These use the stdio transport type and are passed directly to the Claude Code CLI:
options = ClaudeAgent::Options.new(
mcp_servers: {
"filesystem" => {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
},
"database" => {
type: "stdio",
command: "python",
args: ["-m", "mcp_server_sqlite", "--db", "app.db"]
}
}
)You can mix SDK and external servers in the same mcp_servers Hash. The SDK filters out external servers and passes their configuration to the CLI's --mcp-config flag, while SDK servers are handled in-process.
MCP elicitation allows an MCP server to request additional input from the user (or your application) during a tool call -- for example, to handle OAuth consent or collect form data.
Set the on_elicitation callback in your Options. The callback receives a request Hash and must return a response Hash:
options = ClaudeAgent::Options.new(
on_elicitation: ->(request, signal:) {
# request keys:
# :server_name - which MCP server triggered this
# :message - display message from the server
# :mode - elicitation mode
# :url - URL for OAuth flows (if applicable)
# :elicitation_id - unique ID for this elicitation
# :requested_schema - schema describing expected input
case request[:mode]
when "oauth"
# Handle OAuth flow, return approval
{ action: "approve", content: { token: "..." } }
else
# Decline unknown elicitation types
{ action: "decline" }
end
}
)The callback must return a Hash with an action key. Supported actions:
| Action | Description |
|---|---|
"approve" |
Accept the elicitation and optionally provide content |
"decline" |
Reject the elicitation (this is the default if no callback is set) |
If no on_elicitation callback is configured, all elicitation requests are declined automatically.