ruby-sdk/docs/building-servers.md
Koichi ITO 2c295fe930 Document That Tool Arguments Arrive with Symbol Keys
## 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.
2026-06-16 03:09:55 +09:00

9.8 KiB

layout title nav_order
default Building Servers 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:

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:

# 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, 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):

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

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

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

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:

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:

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

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

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:

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

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.