## Motivation and Context The transports parse incoming JSON with `symbolize_names: true`, so a tool receives every key, at every nesting level, as a Ruby symbol. The dispatch in `call_tool_with_args` only runs a shallow `transform_keys(&:to_sym)`, which is effectively a no-op given the deep parse upstream. Nothing in the SDK exercised that delivered shape: the README and examples take flat or primitive arguments, and the tool tests invoke `call` with Ruby keyword arguments, so none go through the JSON parse to dispatch path. A tool that reads a nested object with string keys (`payload["subject"]`) therefore passes its tests and then returns nil against a real client. Ruby keyword arguments bind only on symbol keys, so the top-level keys must be symbols for the ergonomic `def call(message:, ...)` API to work. Aligning fully with the JSON wire shape that the spec and the Python and TypeScript SDKs use (string keys) is not possible without a breaking change to that API. This change therefore documents the existing contract rather than altering behavior. Closes #412 ## How Has This Been Tested? New regression tests in `test/mcp/server_test.rb`: - `tools/call` through the full `handle_json` path delivers nested object arguments with symbol keys at every level, and string-key access on a nested value returns nil. - A tool called under the JSON-round-tripped argument shape receives symbol keys, mirroring what a transport hands it at runtime. ## Breaking Changes None. This is a documentation and test change with no behavior change. The `lib/mcp/server.rb` edit is a clarifying comment on the existing key handling. ## Additional context Making nested objects tolerant of both string and symbol keys (indifferent access) is a possible future improvement that would remove the footgun and move closer to the other SDKs. It is intentionally left out of scope here and can be designed separately, since it is a behavior and dependency decision rather than a documentation fix.
322 lines
9.8 KiB
Markdown
322 lines
9.8 KiB
Markdown
---
|
|
layout: default
|
|
title: Building Servers
|
|
nav_order: 3
|
|
---
|
|
|
|
# Building an MCP Server
|
|
|
|
The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. It implements the Model Context Protocol specification.
|
|
|
|
## Supported Methods
|
|
|
|
- `initialize` - Initializes the protocol and returns server capabilities
|
|
- `ping` - Simple health check
|
|
- `tools/list` - Lists all registered tools and their schemas
|
|
- `tools/call` - Invokes a specific tool with provided arguments
|
|
- `prompts/list` - Lists all registered prompts and their schemas
|
|
- `prompts/get` - Retrieves a specific prompt by name
|
|
- `resources/list` - Lists all registered resources and their schemas
|
|
- `resources/read` - Retrieves a specific resource by name
|
|
- `resources/templates/list` - Lists all registered resource templates and their schemas
|
|
- `resources/subscribe` - Subscribes to updates for a specific resource
|
|
- `resources/unsubscribe` - Unsubscribes from updates for a specific resource
|
|
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
|
|
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
|
|
|
|
## Stdio Transport
|
|
|
|
If you want to build a local command-line application, you can use the stdio transport:
|
|
|
|
```ruby
|
|
require "mcp"
|
|
|
|
class ExampleTool < MCP::Tool
|
|
description "A simple example tool that echoes back its arguments"
|
|
input_schema(
|
|
properties: {
|
|
message: { type: "string" },
|
|
},
|
|
required: ["message"]
|
|
)
|
|
|
|
class << self
|
|
def call(message:, server_context:)
|
|
MCP::Tool::Response.new([{
|
|
type: "text",
|
|
text: "Hello from example tool! Message: #{message}",
|
|
}])
|
|
end
|
|
end
|
|
end
|
|
|
|
server = MCP::Server.new(
|
|
name: "example_server",
|
|
tools: [ExampleTool],
|
|
)
|
|
|
|
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
transport.open
|
|
```
|
|
|
|
## Streamable HTTP Transport
|
|
|
|
`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework.
|
|
The following examples show two common integration styles in Rails.
|
|
|
|
{: .important }
|
|
> `MCP::Server::Transports::StreamableHTTPTransport` stores session and SSE stream state in memory,
|
|
> so it must run in a single process. Use a single-process server (e.g., Puma with `workers 0`).
|
|
> Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that
|
|
> do not share memory, which breaks session management and SSE connections.
|
|
>
|
|
> When running multiple server instances behind a load balancer, configure your load balancer to use
|
|
> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always
|
|
> routed to the same instance.
|
|
>
|
|
> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
|
|
|
|
### Rails (mount)
|
|
|
|
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:
|
|
|
|
```ruby
|
|
# config/routes.rb
|
|
server = MCP::Server.new(
|
|
name: "my_server",
|
|
title: "Example Server Display Name",
|
|
version: "1.0.0",
|
|
instructions: "Use the tools of this server as a last resort",
|
|
tools: [SomeTool, AnotherTool],
|
|
prompts: [MyPrompt],
|
|
)
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
|
|
|
Rails.application.routes.draw do
|
|
mount transport => "/mcp"
|
|
end
|
|
```
|
|
|
|
`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches
|
|
`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE),
|
|
`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per
|
|
the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http),
|
|
so no additional route configuration is needed.
|
|
|
|
### Rails (controller)
|
|
|
|
While the mount approach creates a single server at boot time, the controller approach creates a new server per request.
|
|
This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route).
|
|
|
|
`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications):
|
|
|
|
```ruby
|
|
class McpController < ActionController::API
|
|
def create
|
|
server = MCP::Server.new(
|
|
name: "my_server",
|
|
title: "Example Server Display Name",
|
|
version: "1.0.0",
|
|
instructions: "Use the tools of this server as a last resort",
|
|
tools: [SomeTool, AnotherTool],
|
|
prompts: [MyPrompt],
|
|
server_context: { user_id: current_user.id },
|
|
)
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
|
|
status, headers, body = transport.handle_request(request)
|
|
|
|
render(json: body.first, status: status, headers: headers)
|
|
end
|
|
end
|
|
```
|
|
|
|
## Tools
|
|
|
|
Tools provide functionality to LLM applications. There are three ways to define tools:
|
|
|
|
### Class Definition
|
|
|
|
```ruby
|
|
class MyTool < MCP::Tool
|
|
title "My Tool"
|
|
description "This tool performs specific functionality..."
|
|
input_schema(
|
|
properties: {
|
|
message: { type: "string" },
|
|
},
|
|
required: ["message"]
|
|
)
|
|
annotations(
|
|
read_only_hint: true,
|
|
destructive_hint: false,
|
|
)
|
|
|
|
def self.call(message:, server_context:)
|
|
MCP::Tool::Response.new([{ type: "text", text: "OK" }])
|
|
end
|
|
end
|
|
```
|
|
|
|
### Block Definition
|
|
|
|
```ruby
|
|
tool = MCP::Tool.define(
|
|
name: "my_tool",
|
|
description: "This tool performs specific functionality...",
|
|
) do |args, server_context:|
|
|
MCP::Tool::Response.new([{ type: "text", text: "OK" }])
|
|
end
|
|
```
|
|
|
|
### Server-level Definition
|
|
|
|
```ruby
|
|
server = MCP::Server.new
|
|
server.define_tool(
|
|
name: "my_tool",
|
|
description: "This tool performs specific functionality...",
|
|
) do |args, server_context:|
|
|
MCP::Tool::Response.new([{ type: "text", text: "OK" }])
|
|
end
|
|
```
|
|
|
|
### Tool argument keys
|
|
|
|
Tool arguments are delivered as a `Hash` whose keys are Ruby symbols at every nesting level, including nested objects
|
|
and objects inside arrays. The transports parse incoming JSON with `JSON.parse(..., symbolize_names: true)`,
|
|
so by the time a tool runs, a wire payload such as `{"payload": {"subject": "greet"}}` arrives as `{ payload: { subject: "greet" } }`.
|
|
|
|
This means top-level values are bound through keyword arguments (`def call(message:, payload: nil, server_context:)`),
|
|
and nested objects must be read with symbol keys:
|
|
|
|
```ruby
|
|
class ExampleTool < MCP::Tool
|
|
description "Echoes a nested argument"
|
|
input_schema(
|
|
properties: {
|
|
message: { type: "string" },
|
|
payload: {
|
|
type: "object",
|
|
properties: {
|
|
subject: { type: "string" },
|
|
}
|
|
}
|
|
},
|
|
required: ["message"]
|
|
)
|
|
|
|
def self.call(message:, payload: nil, server_context:)
|
|
subject = payload && payload[:subject] # symbol key, not payload["subject"]
|
|
MCP::Tool::Response.new([{
|
|
type: "text",
|
|
text: "Message: #{message}; subject: #{subject}"
|
|
}])
|
|
end
|
|
end
|
|
```
|
|
|
|
Reading a nested value with a string key (`payload["subject"]`) returns `nil`. This is a Ruby-specific contract:
|
|
Top-level keyword arguments require symbol keys, and parsing JSON with `symbolize_names: true` symbolizes nested objects too.
|
|
|
|
Calling a tool directly in a test with `MyTool.call(payload: { "subject" => "greet" }, server_context: nil)` passes string keys
|
|
that a transport never delivers, so string-key access can pass tests yet fail against a real client.
|
|
Exercise a tool under the delivered shape by round-tripping the arguments through JSON the same way a transport does:
|
|
|
|
```ruby
|
|
delivered = JSON.parse(JSON.generate(arguments), symbolize_names: true)
|
|
MyTool.call(**delivered, server_context: nil)
|
|
```
|
|
|
|
## Prompts
|
|
|
|
Prompts are templates for LLM interactions. Like tools, they can be defined in three ways:
|
|
|
|
### Class Definition
|
|
|
|
```ruby
|
|
class CodeReviewPrompt < MCP::Prompt
|
|
prompt_name "code_review"
|
|
description "Review code for best practices"
|
|
arguments [
|
|
MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true),
|
|
]
|
|
|
|
class << self
|
|
def template(args, server_context:)
|
|
MCP::Prompt::Result.new(
|
|
description: "Code review",
|
|
messages: [
|
|
MCP::Prompt::Message.new(
|
|
role: "user",
|
|
content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}")
|
|
),
|
|
]
|
|
)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
### Server-level Definition
|
|
|
|
```ruby
|
|
server.define_prompt(
|
|
name: "code_review",
|
|
description: "Review code for best practices",
|
|
arguments: [
|
|
MCP::Prompt::Argument.new(name: "code", description: "Code to review", required: true),
|
|
]
|
|
) do |args, server_context:|
|
|
MCP::Prompt::Result.new(
|
|
description: "Code review",
|
|
messages: [
|
|
MCP::Prompt::Message.new(
|
|
role: "user",
|
|
content: MCP::Content::Text.new("Please review this code:\n#{args[:code]}")
|
|
),
|
|
]
|
|
)
|
|
end
|
|
```
|
|
|
|
## Resources
|
|
|
|
Resources provide data access to LLM applications:
|
|
|
|
```ruby
|
|
class MyResource < MCP::Resource
|
|
uri "file:///data/config.json"
|
|
resource_name "config"
|
|
description "Application configuration"
|
|
mime_type "application/json"
|
|
end
|
|
|
|
server = MCP::Server.new(
|
|
name: "my_server",
|
|
resources: [MyResource],
|
|
resources_read_handler: ->(uri, _server_context) {
|
|
case uri
|
|
when "file:///data/config.json"
|
|
{ uri: uri, text: File.read("config.json"), mimeType: "application/json" }
|
|
end
|
|
}
|
|
)
|
|
```
|
|
|
|
## Configuration
|
|
|
|
```ruby
|
|
MCP.configure do |config|
|
|
config.exception_reporter = ->(exception, server_context) {
|
|
Bugsnag.notify(exception) do |report|
|
|
report.add_metadata(:model_context_protocol, server_context)
|
|
end
|
|
}
|
|
|
|
config.instrumentation_callback = ->(data) {
|
|
puts "Got instrumentation data #{data.inspect}"
|
|
}
|
|
end
|
|
```
|
|
|
|
For more details on sampling, notifications, progress tracking, completions, logging, and advanced features, see the [full README](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/README.md#building-an-mcp-server).
|