πŸ› οΈtutorialadvanced

Testing Your AI Tools: RSpec Strategies for the MCP SDK

Learn to test MCP servers without needing Claude. Unit test your tools, validate schemas, and mock the server with proper RSpec patterns.

ByMCP Directory Team
Published
⏱️35 minutes
rubyrspectestingtddbest-practices

Testing Your AI Tools: RSpec Strategies for the MCP SDK

An MCP server is just code. You shouldn't need to fire up Claude to verify your tools work. This guide shows you how to test MCP tools with RSpec, enabling TDD workflows for AI integrations.

Table of Contents

  1. The Testing Challenge
  2. Project Setup
  3. Unit Testing Tools
  4. Schema Validation
  5. Mocking the Server
  6. Integration Testing
  7. Testing Resources
  8. Advanced Patterns
  9. CI/CD Integration

The Testing Challenge

MCP tools typically run in a special context:

  • They're registered on a server
  • They receive input from an LLM
  • They return results that the LLM interprets

But you don't need any of that to test the actual logic. Your tool block is just Ruby codeβ€”test it like any other Ruby code.

Why Test MCP Tools?

  1. Confidence: Know your tools work before Claude calls them
  2. Speed: Test in milliseconds, not minutes of LLM interaction
  3. Regression: Catch bugs when refactoring
  4. Documentation: Specs describe expected behavior
  5. TDD: Write tests first, implement second

Project Setup

Directory Structure

my-mcp-server/
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ mcp_server.rb          # Main server setup
β”‚   └── tools/
β”‚       β”œβ”€β”€ weather_tool.rb
β”‚       β”œβ”€β”€ calculator_tool.rb
β”‚       └── user_search_tool.rb
β”œβ”€β”€ spec/
β”‚   β”œβ”€β”€ spec_helper.rb
β”‚   β”œβ”€β”€ tools/
β”‚   β”‚   β”œβ”€β”€ weather_tool_spec.rb
β”‚   β”‚   β”œβ”€β”€ calculator_tool_spec.rb
β”‚   β”‚   └── user_search_tool_spec.rb
β”‚   └── integration/
β”‚       └── server_spec.rb
β”œβ”€β”€ Gemfile
└── server.rb

Gemfile

source 'https://rubygems.org'

gem 'mcp-sdk'

group :test do
  gem 'rspec', '~> 3.12'
  gem 'webmock'          # For mocking HTTP requests
  gem 'factory_bot'      # For test data
  gem 'database_cleaner' # For Rails/DB tests
end

spec_helper.rb

require 'rspec'
require 'webmock/rspec'

# Load your tools
Dir[File.join(__dir__, '../lib/**/*.rb')].each { |f| require f }

RSpec.configure do |config|
  config.expect_with :rspec do |c|
    c.syntax = :expect
  end

  # Disable external HTTP requests during tests
  WebMock.disable_net_connect!(allow_localhost: true)
end

Unit Testing Tools

The key insight: extract your tool logic into testable units.

Pattern 1: Extract the Block

Instead of defining tools inline, extract the logic:

# lib/tools/weather_tool.rb
module Tools
  class WeatherTool
    SCHEMA = {
      type: "object",
      properties: {
        location: {
          type: "string",
          description: "City name or coordinates"
        },
        units: {
          type: "string",
          enum: ["celsius", "fahrenheit"],
          description: "Temperature units"
        }
      },
      required: ["location"]
    }.freeze

    def self.name
      "get_weather"
    end

    def self.description
      "Get current weather for a location"
    end

    def self.input_schema
      SCHEMA
    end

    # The actual logic - easily testable!
    def self.call(input)
      location = input[:location]
      units = input[:units] || "celsius"

      # Fetch weather (mocked in tests)
      weather_data = WeatherAPI.fetch(location)

      {
        location: location,
        temperature: convert_temp(weather_data[:temp], units),
        units: units,
        conditions: weather_data[:conditions]
      }
    end

    def self.convert_temp(celsius, units)
      units == "fahrenheit" ? (celsius * 9/5) + 32 : celsius
    end
  end
end

Testing the Extracted Tool

# spec/tools/weather_tool_spec.rb
require 'spec_helper'

RSpec.describe Tools::WeatherTool do
  describe '.call' do
    before do
      # Mock the external API
      allow(WeatherAPI).to receive(:fetch).and_return({
        temp: 20,
        conditions: "sunny"
      })
    end

    context 'with valid input' do
      it 'returns weather data for a location' do
        result = described_class.call({ location: "San Francisco" })

        expect(result).to include(
          location: "San Francisco",
          temperature: 20,
          units: "celsius",
          conditions: "sunny"
        )
      end

      it 'converts to fahrenheit when requested' do
        result = described_class.call({
          location: "San Francisco",
          units: "fahrenheit"
        })

        expect(result[:temperature]).to eq(68) # 20Β°C = 68Β°F
        expect(result[:units]).to eq("fahrenheit")
      end
    end

    context 'with missing location' do
      it 'raises an error' do
        expect {
          described_class.call({})
        }.to raise_error(KeyError)
      end
    end
  end

  describe '.convert_temp' do
    it 'converts celsius to fahrenheit' do
      expect(described_class.convert_temp(0, "fahrenheit")).to eq(32)
      expect(described_class.convert_temp(100, "fahrenheit")).to eq(212)
    end

    it 'returns celsius unchanged' do
      expect(described_class.convert_temp(20, "celsius")).to eq(20)
    end
  end
end

Pattern 2: Tool Registry

Create a registry for easy testing:

# lib/tool_registry.rb
module ToolRegistry
  @tools = {}

  def self.register(name, &block)
    @tools[name] = block
  end

  def self.call(name, input)
    tool = @tools[name]
    raise "Unknown tool: #{name}" unless tool
    tool.call(input)
  end

  def self.[](name)
    @tools[name]
  end

  def self.all
    @tools
  end
end

# Usage in your server
ToolRegistry.register("get_weather") do |input|
  # Tool logic here
end

# Then in tests
RSpec.describe "WeatherTool" do
  let(:tool) { ToolRegistry["get_weather"] }

  it "returns weather data" do
    result = tool.call({ location: "NYC" })
    expect(result).to have_key(:temperature)
  end
end

Schema Validation

Your tools define JSON schemas. Test that your implementation actually follows them!

Install JSON Schema Validator

# Gemfile
gem 'json_schemer'

Schema Validation Specs

# spec/tools/calculator_tool_spec.rb
require 'spec_helper'
require 'json_schemer'

RSpec.describe Tools::CalculatorTool do
  let(:input_schema) { JSONSchemer.schema(described_class.input_schema) }

  describe 'input schema validation' do
    it 'accepts valid input' do
      valid_input = { expression: "2 + 2" }
      expect(input_schema.valid?(valid_input)).to be true
    end

    it 'rejects missing required fields' do
      invalid_input = {}
      expect(input_schema.valid?(invalid_input)).to be false
    end

    it 'rejects wrong types' do
      invalid_input = { expression: 123 }  # Should be string
      expect(input_schema.valid?(invalid_input)).to be false
    end
  end

  describe 'output format' do
    it 'returns expected structure' do
      result = described_class.call({ expression: "2 + 2" })

      expect(result).to be_a(Hash)
      expect(result).to have_key(:result)
      expect(result[:result]).to be_a(Numeric)
    end
  end
end

Shared Schema Validation Examples

# spec/support/shared_examples/schema_validation.rb
RSpec.shared_examples "validates against schema" do |valid_inputs, invalid_inputs|
  let(:schema) { JSONSchemer.schema(described_class.input_schema) }

  valid_inputs.each do |input|
    it "accepts valid input: #{input.inspect}" do
      expect(schema.valid?(input)).to be true
    end
  end

  invalid_inputs.each do |input|
    it "rejects invalid input: #{input.inspect}" do
      expect(schema.valid?(input)).to be false
    end
  end
end

# Usage
RSpec.describe Tools::UserSearchTool do
  include_examples "validates against schema",
    [
      { query: "john" },
      { query: "john", role: "admin" },
      { query: "john", limit: 10 }
    ],
    [
      {},                           # Missing required
      { query: 123 },               # Wrong type
      { query: "john", limit: -1 }, # Below minimum
      { query: "john", limit: 100 } # Above maximum
    ]
end

Mocking the Server

Sometimes you need to test how tools integrate with the server:

# spec/support/mock_server.rb
class MockMCPServer
  attr_reader :tools, :resources

  def initialize
    @tools = {}
    @resources = {}
  end

  def register_tool(name:, description:, input_schema:, &block)
    @tools[name] = {
      description: description,
      input_schema: input_schema,
      handler: block
    }
  end

  def register_resource(uri:, name:, mime_type: "text/plain", &block)
    @resources[uri] = {
      name: name,
      mime_type: mime_type,
      handler: block
    }
  end

  def call_tool(name, input)
    tool = @tools[name]
    raise "Unknown tool: #{name}" unless tool
    tool[:handler].call(input)
  end

  def read_resource(uri)
    resource = @resources[uri]
    raise "Unknown resource: #{uri}" unless resource
    resource[:handler].call
  end

  def run
    # No-op for testing
  end
end

Using the Mock Server

# spec/integration/server_spec.rb
require 'spec_helper'
require 'support/mock_server'

RSpec.describe 'MCP Server Integration' do
  let(:server) { MockMCPServer.new }

  before do
    # Load your actual server configuration
    load_server_tools(server)
  end

  describe 'tool registration' do
    it 'registers all expected tools' do
      expect(server.tools.keys).to contain_exactly(
        "get_weather",
        "calculate",
        "find_users",
        "create_order"
      )
    end

    it 'provides descriptions for all tools' do
      server.tools.each do |name, tool|
        expect(tool[:description]).not_to be_empty,
          "Tool '#{name}' should have a description"
      end
    end
  end

  describe 'tool execution' do
    it 'executes weather tool successfully' do
      allow(WeatherAPI).to receive(:fetch).and_return({ temp: 25, conditions: "cloudy" })

      result = server.call_tool("get_weather", { location: "London" })

      expect(result[:temperature]).to eq(25)
    end
  end
end

Integration Testing

Test the full flow without Claude:

# spec/integration/tool_flow_spec.rb
require 'spec_helper'

RSpec.describe 'Tool Flow Integration' do
  # Simulate what Claude would do

  describe 'user lookup flow' do
    let(:server) { MockMCPServer.new }

    before do
      load_server_tools(server)

      # Seed test data
      User.create!(name: "Alice", email: "[email protected]", role: "admin")
      User.create!(name: "Bob", email: "[email protected]", role: "user")
    end

    it 'finds users by email' do
      result = server.call_tool("find_users", { query: "[email protected]" })

      expect(result[:users]).to have(1).item
      expect(result[:users].first["name"]).to eq("Alice")
    end

    it 'filters by role' do
      result = server.call_tool("find_users", {
        query: "@example.com",
        role: "admin"
      })

      expect(result[:users]).to have(1).item
      expect(result[:users].first["name"]).to eq("Alice")
    end
  end

  describe 'order creation flow' do
    it 'creates an order and returns confirmation' do
      result = server.call_tool("create_order", {
        customer_id: 1,
        items: [{ product_id: 42, quantity: 2 }]
      })

      expect(result[:success]).to be true
      expect(result[:order_number]).to match(/^ORD-\d+$/)
    end
  end
end

Testing Resources

Resources are simpler to test:

# spec/resources/log_resource_spec.rb
require 'spec_helper'

RSpec.describe 'Log Resource' do
  let(:server) { MockMCPServer.new }
  let(:log_path) { Rails.root.join("tmp/test.log") }

  before do
    # Create test log file
    File.write(log_path, <<~LOG)
      2025-01-01 10:00:00 INFO Starting application
      2025-01-01 10:00:01 DEBUG Connection established
      2025-01-01 10:00:02 ERROR Something went wrong
      2025-01-01 10:00:03 INFO Request processed
    LOG

    # Register the resource
    server.register_resource(
      uri: "file:///logs/app.log",
      name: "Application Logs",
      mime_type: "text/plain"
    ) do
      File.read(log_path)
    end
  end

  after do
    File.delete(log_path) if File.exist?(log_path)
  end

  it 'returns log file contents' do
    content = server.read_resource("file:///logs/app.log")

    expect(content).to include("INFO Starting application")
    expect(content).to include("ERROR Something went wrong")
  end

  it 'is registered with correct mime type' do
    resource = server.resources["file:///logs/app.log"]
    expect(resource[:mime_type]).to eq("text/plain")
  end
end

Advanced Patterns

Testing Error Handling

RSpec.describe Tools::WeatherTool do
  describe 'error handling' do
    context 'when API is unavailable' do
      before do
        allow(WeatherAPI).to receive(:fetch).and_raise(
          Faraday::ConnectionFailed.new("Connection refused")
        )
      end

      it 'returns an error response' do
        result = described_class.call({ location: "NYC" })

        expect(result[:error]).to eq("Weather service unavailable")
        expect(result[:success]).to be false
      end
    end

    context 'when location is not found' do
      before do
        allow(WeatherAPI).to receive(:fetch).and_return(nil)
      end

      it 'returns location not found error' do
        result = described_class.call({ location: "Atlantis" })

        expect(result[:error]).to eq("Location not found")
      end
    end
  end
end

Testing Rate Limiting

RSpec.describe Tools::SearchTool do
  describe 'rate limiting' do
    it 'enforces result limits' do
      # Create 100 test records
      100.times { |i| User.create!(name: "User #{i}") }

      result = described_class.call({
        query: "User",
        limit: 1000  # Attempting to get all
      })

      # Should be capped at max limit
      expect(result[:users].length).to be <= 50
    end
  end
end

Testing Concurrent Access

RSpec.describe Tools::CounterTool do
  describe 'thread safety' do
    it 'handles concurrent increments' do
      threads = 10.times.map do
        Thread.new do
          described_class.call({ action: "increment" })
        end
      end

      threads.each(&:join)

      result = described_class.call({ action: "get" })
      expect(result[:count]).to eq(10)
    end
  end
end

CI/CD Integration

GitHub Actions Workflow

# .github/workflows/test.yml
name: Test MCP Server

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Run RSpec
        run: bundle exec rspec --format documentation

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        if: success()

Pre-commit Hook

#!/bin/bash
# .git/hooks/pre-commit

echo "Running MCP tool tests..."
bundle exec rspec spec/tools/ --fail-fast

if [ $? -ne 0 ]; then
  echo "Tests failed. Commit aborted."
  exit 1
fi

Complete Test Suite Example

# spec/tools/user_search_tool_spec.rb
require 'spec_helper'
require 'json_schemer'

RSpec.describe Tools::UserSearchTool do
  let(:schema) { JSONSchemer.schema(described_class.input_schema) }

  describe '.input_schema' do
    it 'requires query parameter' do
      expect(schema.valid?({})).to be false
      expect(schema.valid?({ query: "test" })).to be true
    end

    it 'validates limit bounds' do
      expect(schema.valid?({ query: "test", limit: 0 })).to be false
      expect(schema.valid?({ query: "test", limit: 100 })).to be false
      expect(schema.valid?({ query: "test", limit: 10 })).to be true
    end

    it 'validates role enum' do
      expect(schema.valid?({ query: "test", role: "invalid" })).to be false
      expect(schema.valid?({ query: "test", role: "admin" })).to be true
    end
  end

  describe '.call' do
    before do
      User.delete_all
      User.create!(name: "Alice Admin", email: "[email protected]", role: "admin")
      User.create!(name: "Bob User", email: "[email protected]", role: "user")
      User.create!(name: "Carol User", email: "[email protected]", role: "user")
    end

    it 'searches by name' do
      result = described_class.call({ query: "Alice" })
      expect(result[:users].length).to eq(1)
      expect(result[:users].first["name"]).to eq("Alice Admin")
    end

    it 'searches by email' do
      result = described_class.call({ query: "[email protected]" })
      expect(result[:users].length).to eq(1)
    end

    it 'filters by role' do
      result = described_class.call({ query: "@test.com", role: "user" })
      expect(result[:users].length).to eq(2)
    end

    it 'respects limit' do
      result = described_class.call({ query: "@test.com", limit: 1 })
      expect(result[:users].length).to eq(1)
    end

    it 'returns safe fields only' do
      result = described_class.call({ query: "Alice" })
      user = result[:users].first

      expect(user).to have_key("id")
      expect(user).to have_key("name")
      expect(user).to have_key("email")
      expect(user).not_to have_key("password_digest")
      expect(user).not_to have_key("api_token")
    end

    it 'returns count with results' do
      result = described_class.call({ query: "@test.com" })
      expect(result[:count]).to eq(3)
    end

    context 'with no matches' do
      it 'returns empty array' do
        result = described_class.call({ query: "nonexistent" })
        expect(result[:users]).to eq([])
        expect(result[:count]).to eq(0)
      end
    end

    context 'SQL injection attempt' do
      it 'safely handles malicious input' do
        # Should not raise or expose data
        result = described_class.call({ query: "'; DROP TABLE users; --" })
        expect(result[:users]).to eq([])
        expect(User.count).to eq(3) # Table still exists
      end
    end
  end

  describe '.name' do
    it 'returns the tool name' do
      expect(described_class.name).to eq("find_users")
    end
  end

  describe '.description' do
    it 'has a meaningful description' do
      expect(described_class.description).to include("Search")
      expect(described_class.description.length).to be > 10
    end
  end
end

Summary

Testing MCP tools is just like testing any Ruby code:

  1. Extract logic into testable classes/methods
  2. Mock external dependencies (APIs, databases)
  3. Validate schemas match implementation
  4. Test error cases and edge conditions
  5. Integrate with CI for continuous validation

You now have a robust testing strategy that lets you develop MCP tools with confidenceβ€”without needing an LLM in the loop.

Next Steps