Tools

MCP spec includes Tools which provide functionality to LLM apps.

Defining Tools

This gem provides a MCP::Tool class that can be used to create tools in three ways.

1. As a class definition

Subclass MCP::Tool and declare the metadata with class-level helpers; call implements the tool:

class MyTool < MCP::Tool
  title "My Tool"
  description "This tool performs specific functionality..."
  input_schema(
    properties: {
      message: { type: "string" },
    },
    required: ["message"]
  )
  output_schema(
    properties: {
      result: { type: "string" },
      success: { type: "boolean" },
      timestamp: { type: "string", format: "date-time" }
    },
    required: ["result", "success", "timestamp"]
  )
  annotations(
    read_only_hint: true,
    destructive_hint: false,
    idempotent_hint: true,
    open_world_hint: false,
    title: "My Tool"
  )

  def self.call(message:, server_context:)
    MCP::Tool::Response.new([{ type: "text", text: "OK" }])
  end
end

tool = MyTool

2. Using the MCP::Tool.define method

MCP::Tool.define builds a tool from keyword arguments, with the block as its implementation:

tool = MCP::Tool.define(
  name: "my_tool",
  title: "My Tool",
  description: "This tool performs specific functionality...",
  annotations: {
    read_only_hint: true,
    title: "My Tool"
  }
) do |args, server_context:|
  MCP::Tool::Response.new([{ type: "text", text: "OK" }])
end

3. Using the MCP::Server#define_tool method

MCP::Server#define_tool registers the tool directly on a server instance:

server = MCP::Server.new
server.define_tool(
  name: "my_tool",
  description: "This tool performs specific functionality...",
  annotations: {
    title: "My Tool",
    read_only_hint: true
  }
) do |args, server_context:|
  Tool::Response.new([{ type: "text", text: "OK" }])
end

The server_context parameter is the server_context passed into the server and can be used to pass per request information, e.g. around authentication state.

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)

Tool Annotations

Tools can include annotations that provide additional metadata about their behavior. The following annotations are supported:

  • destructive_hint: Indicates if the tool performs destructive operations. Defaults to true
  • idempotent_hint: Indicates if the tool’s operations are idempotent. Defaults to false
  • open_world_hint: Indicates if the tool operates in an open world context. Defaults to true
  • read_only_hint: Indicates if the tool only reads data (doesn’t modify state). Defaults to false
  • title: A human-readable title for the tool

Annotations can be set either through the class definition using the annotations class method or when defining a tool using the define method.

This Tool Annotations feature is supported starting from protocol_version: '2025-03-26'.

Tool Output Schemas

Tools can optionally define an output_schema to specify the expected structure of their results. This works similarly to how input_schema is defined and can be used in three ways.

1. Class definition with output_schema

Declare the schema with the output_schema class helper, alongside input_schema:

class WeatherTool < MCP::Tool
  tool_name "get_weather"
  description "Get current weather for a location"

  input_schema(
    properties: {
      location: { type: "string" },
      units: { type: "string", enum: ["celsius", "fahrenheit"] }
    },
    required: ["location"]
  )

  output_schema(
    properties: {
      temperature: { type: "number" },
      condition: { type: "string" },
      humidity: { type: "integer" }
    },
    required: ["temperature", "condition", "humidity"]
  )

  def self.call(location:, units: "celsius", server_context:)
    # Call weather API and structure the response
    api_response = WeatherAPI.fetch(location, units)
    weather_data = {
      temperature: api_response.temp,
      condition: api_response.description,
      humidity: api_response.humidity_percent
    }

    output_schema.validate_result(weather_data)

    MCP::Tool::Response.new([{
      type: "text",
      text: weather_data.to_json
    }])
  end
end

2. Using Tool.define with output_schema

Pass the schema as the output_schema: keyword argument:

tool = MCP::Tool.define(
  name: "calculate_stats",
  description: "Calculate statistics for a dataset",
  input_schema: {
    properties: {
      numbers: { type: "array", items: { type: "number" } }
    },
    required: ["numbers"]
  },
  output_schema: {
    properties: {
      mean: { type: "number" },
      median: { type: "number" },
      count: { type: "integer" }
    },
    required: ["mean", "median", "count"]
  }
) do |args, server_context:|
  # Calculate statistics and validate against schema
  MCP::Tool::Response.new([{ type: "text", text: "Statistics calculated" }])
end

3. Using OutputSchema objects

Construct an MCP::Tool::OutputSchema object explicitly:

class DataTool < MCP::Tool
  output_schema MCP::Tool::OutputSchema.new(
    properties: {
      success: { type: "boolean" },
      data: { type: "object" }
    },
    required: ["success"]
  )
end

Output schema may also describe an array of objects:

class WeatherTool < MCP::Tool
  output_schema(
    type: "array",
    items: {
      properties: {
        temperature: { type: "number" },
        condition: { type: "string" },
        humidity: { type: "integer" }
      },
      required: ["temperature", "condition", "humidity"]
    }
  )
end

Please note: in this case, you must provide type: "array". The default type for output schemas is object, applied only when the schema declares no root keyword (type, $ref, oneOf, anyOf, allOf, not, if, const, enum).

Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root ({ type: "string" }) or a root-level composition:

class FlexibleTool < MCP::Tool
  output_schema(
    oneOf: [
      { type: "string" },
      { type: "array", items: { type: "number" } }
    ]
  )
end

Input schemas keep type: "object" at the root but accept the full 2020-12 vocabulary below it ($defs/$ref, oneOf/anyOf/allOf/not, if/then/else). Two resource bounds apply to all tool schemas: only same-document $refs (starting with #) are accepted, and documents are capped at MCP::Tool::Schema::MAX_SCHEMA_DEPTH nesting levels and MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT subschema objects; violations raise ArgumentError at construction time.

MCP spec for the Output Schema specifies that:

  • Server Validation: Servers MUST provide structured results that conform to the output schema
  • Client Validation: Clients SHOULD validate structured results against the output schema
  • Better Integration: Enables strict schema validation, type information, and improved developer experience
  • Backward Compatibility: Tools returning structured content SHOULD also include serialized JSON in a TextContent block

The output schema follows standard JSON Schema format and helps ensure consistent data exchange between MCP servers and clients.

By default, server-side validation of tool results against output_schema is disabled for backwards compatibility. To validate successful tool responses, enable validate_tool_call_results on the server configuration:

configuration = MCP::Configuration.new(validate_tool_call_results: true)
server = MCP::Server.new(
  name: "example_server",
  tools: [WeatherTool],
  configuration: configuration
)

When enabled, successful tool responses for tools with an output_schema must include structured_content that conforms to the schema. Error responses are not validated against the output schema.

Tool Responses with Structured Content

Tools can return structured data alongside text content using the structured_content parameter.

The structured content will be included in the JSON-RPC response as the structuredContent field.

Per SEP-2106, structured_content may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array) without providing any content blocks, the server automatically mirrors it into content as serialized JSON text so older clients that only read content still receive the data.

class WeatherTool < MCP::Tool
  description "Get current weather and return structured data"

  def self.call(location:, units: "celsius", server_context:)
    # Call weather API and structure the response
    api_response = WeatherAPI.fetch(location, units)
    weather_data = {
      temperature: api_response.temp,
      condition: api_response.description,
      humidity: api_response.humidity_percent
    }

    output_schema.validate_result(weather_data)

    MCP::Tool::Response.new(
      [{
        type: "text",
        text: weather_data.to_json
      }],
      structured_content: weather_data
    )
  end
end

Tool Responses with Errors

Tools can return error information alongside text content using the error parameter.

The error will be included in the JSON-RPC response as the isError field.

class WeatherTool < MCP::Tool
  description "Get current weather and return structured data"

  def self.call(server_context:)
    # Do something here
    content = {}

    MCP::Tool::Response.new(
      [{
        type: "text",
        text: content.to_json
      }],
      structured_content: content,
      error: true
    )
  end
end

Tool Responses with Image, Audio, and Embedded Resources

Tool responses are not limited to text. The MCP::Content module provides Image, Audio, and EmbeddedResource content types, which serialize to the image, audio, and resource content blocks defined by the MCP spec. Image and audio data is passed as a base64-encoded string together with its MIME type:

class ChartTool < MCP::Tool
  description "Render a chart as a PNG image"

  def self.call(server_context:)
    MCP::Tool::Response.new([
      MCP::Content::Text.new("Here is the rendered chart:").to_h,
      MCP::Content::Image.new(Base64.strict_encode64(render_chart_png), "image/png").to_h,
    ])
  end
end

class SpeechTool < MCP::Tool
  description "Synthesize speech audio"

  def self.call(server_context:)
    MCP::Tool::Response.new([
      MCP::Content::Audio.new(Base64.strict_encode64(synthesize_wav), "audio/wav").to_h,
    ])
  end
end

An embedded resource wraps MCP::Resource::TextContents or MCP::Resource::BlobContents, allowing a tool to return resource contents inline:

class ReportTool < MCP::Tool
  description "Return a report as an embedded resource"

  def self.call(server_context:)
    contents = MCP::Resource::TextContents.new(
      uri: "report://monthly",
      mime_type: "application/json",
      text: { total: 42 }.to_json,
    )

    MCP::Tool::Response.new([MCP::Content::EmbeddedResource.new(contents).to_h])
  end
end