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
- The Testing Challenge
- Project Setup
- Unit Testing Tools
- Schema Validation
- Mocking the Server
- Integration Testing
- Testing Resources
- Advanced Patterns
- 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?
- Confidence: Know your tools work before Claude calls them
- Speed: Test in milliseconds, not minutes of LLM interaction
- Regression: Catch bugs when refactoring
- Documentation: Specs describe expected behavior
- 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:
- Extract logic into testable classes/methods
- Mock external dependencies (APIs, databases)
- Validate schemas match implementation
- Test error cases and edge conditions
- 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
- Building Your First MCP Server: Start with the basics
- Understanding Tools vs. Resources: Know when to use each
- Rails ActiveRecord Integration: Connect your database
- Stdio vs. SSE Transport: Deploy with SSE