Understanding MCP: Tools vs. Resources (Explained for Rubyists)
The Model Context Protocol has two main primitives that confuse newcomers: Tools and Resources. If you're a Ruby developer, here's an analogy that will make everything click.
Table of Contents
- The Ruby Analogy
- Tools: Service Objects for AI
- Resources: Passive Data Providers
- When to Use Which?
- Implementation Examples
- Advanced Patterns
- Common Mistakes
The Ruby Analogy
Think of MCP primitives in terms of Ruby concepts you already know:
| MCP Primitive | Ruby Equivalent | Purpose |
|---------------|-----------------|---------|
| Tools | Methods / Service Objects | Execute actions, perform calculations, modify state |
| Resources | attr_reader / Constants / File reads | Provide read-only context and data |
Tools = Methods
# In Ruby, a method DOES something
def send_email(to:, subject:, body:)
Mailer.deliver(to: to, subject: subject, body: body)
end
# In MCP, a Tool DOES something
server.register_tool(name: "send_email", ...) do |input|
Mailer.deliver(to: input[:to], subject: input[:subject], body: input[:body])
end
Resources = Readers
# In Ruby, attr_reader EXPOSES data
class Config
attr_reader :database_url, :api_key
end
# In MCP, a Resource EXPOSES data
server.register_resource(uri: "config://database", ...) do
{ database_url: ENV['DATABASE_URL'] }
end
Tools: Service Objects for AI
Tools are executable functions. When Claude calls a tool, your code does something:
- Creates a database record
- Sends an API request
- Performs a calculation
- Executes a shell command
- Modifies external state
Anatomy of a Tool
server.register_tool(
name: "create_user", # Unique identifier (snake_case)
description: "Creates a new user account in the system", # Tells Claude when to use it
input_schema: { # JSON Schema for parameters
type: "object",
properties: {
email: { type: "string", format: "email" },
name: { type: "string" },
role: { type: "string", enum: ["admin", "user"] }
},
required: ["email", "name"]
}
) do |input|
# This block executes when Claude calls the tool
user = User.create!(
email: input[:email],
name: input[:name],
role: input[:role] || "user"
)
{ success: true, user_id: user.id }
end
When Tools Are Called
- User asks Claude something that requires action
- Claude determines which tool can help
- Claude constructs the input based on the schema
- Your Ruby block executes
- Result is returned to Claude
- Claude formulates a response
Example Flow:
User: "Create an account for [email protected]"
Claude: [Calls create_user tool with {email: "[email protected]", name: "John"}]
Tool: [Returns {success: true, user_id: 42}]
Claude: "I've created an account for John. The new user ID is 42."
Resources: Passive Data Providers
Resources are contextual data sources. They provide information that Claude can read to understand your system:
- Configuration files
- Log files
- Database schemas
- Documentation
- Static reference data
Anatomy of a Resource
server.register_resource(
uri: "file:///app/db/schema.rb", # Unique identifier (URI format)
name: "Database Schema", # Human-readable name
description: "Current Rails database schema", # What this resource contains
mime_type: "text/plain" # Content type
) do
# This block returns the resource content
File.read(Rails.root.join("db/schema.rb"))
end
Resources vs. Tools: The Key Difference
Resources are proactively read. Claude can request resource content before answering, using it as context. They're like giving Claude a reference document.
Tools are reactively called. Claude calls them to perform actions based on user requests.
# RESOURCE: Claude reads this to understand your database
server.register_resource(uri: "schema://database") do
# Returns schema for Claude to read
ActiveRecord::Base.connection.tables.map do |table|
columns = ActiveRecord::Base.connection.columns(table)
{ table: table, columns: columns.map { |c| { name: c.name, type: c.type } } }
end
end
# TOOL: Claude calls this to actually query the database
server.register_tool(name: "query_database", ...) do |input|
# Executes a query and returns results
Model.where(input[:conditions]).limit(10).as_json
end
When to Use Which?
Use a Tool When:
| Scenario | Why Tool? | |----------|-----------| | Creating/updating records | Modifies state | | Sending emails/notifications | Side effects | | Making API calls | External actions | | Running calculations | Active computation | | Executing commands | System interaction | | Searching with parameters | Dynamic queries |
Use a Resource When:
| Scenario | Why Resource? | |----------|---------------| | Exposing log files | Static read-only data | | Sharing schema/structure | Reference information | | Providing documentation | Context for Claude | | Offering configuration | Read-only settings | | Database structure info | Static metadata |
The Decision Flowchart
Does Claude need to DO something?
βββ YES β Use a TOOL
β βββ Will it modify state or have side effects?
β βββ YES β Definitely a Tool
β βββ NO β Could be either, but Tool gives more control
β
βββ NO β Claude just needs to KNOW something?
βββ YES β Use a RESOURCE
βββ Is the data static or slowly-changing?
βββ YES β Perfect for Resource
βββ NO β Consider a Tool for fresh data
Implementation Examples
Example 1: Log File Access
# RESOURCE: Let Claude read recent logs
server.register_resource(
uri: "file:///app/logs/production.log",
name: "Production Logs",
description: "Last 100 lines of the production log file",
mime_type: "text/plain"
) do
# Read the last 100 lines of the log file
log_path = Rails.root.join("log/production.log")
if File.exist?(log_path)
File.readlines(log_path).last(100).join
else
"Log file not found"
end
end
Usage: Claude can read this to understand recent application behavior when debugging.
Example 2: API Documentation
# RESOURCE: Expose your API docs to Claude
server.register_resource(
uri: "docs://api/endpoints",
name: "API Endpoints",
description: "List of all available API endpoints with their methods and parameters",
mime_type: "application/json"
) do
# Generate from Rails routes
routes = Rails.application.routes.routes.map do |route|
{
method: route.verb,
path: route.path.spec.to_s,
controller: route.defaults[:controller],
action: route.defaults[:action]
}
end
routes.to_json
end
Example 3: Environment Info
# RESOURCE: System context
server.register_resource(
uri: "system://environment",
name: "Environment Info",
description: "Current runtime environment details",
mime_type: "application/json"
) do
{
rails_env: Rails.env,
ruby_version: RUBY_VERSION,
rails_version: Rails.version,
database_adapter: ActiveRecord::Base.connection.adapter_name,
time: Time.current.iso8601
}.to_json
end
Example 4: Tool for Dynamic Search
# TOOL: Search is dynamic and parameterized
server.register_tool(
name: "search_logs",
description: "Search log files for specific patterns",
input_schema: {
type: "object",
properties: {
pattern: { type: "string", description: "Regex pattern to search for" },
log_file: {
type: "string",
enum: ["production", "development", "test"],
description: "Which log file to search"
},
context_lines: {
type: "integer",
minimum: 0,
maximum: 10,
description: "Lines of context around matches"
}
},
required: ["pattern"]
}
) do |input|
log_file = input[:log_file] || "production"
log_path = Rails.root.join("log/#{log_file}.log")
unless File.exist?(log_path)
next { error: "Log file not found" }
end
pattern = Regexp.new(input[:pattern], Regexp::IGNORECASE)
context = input[:context_lines] || 2
lines = File.readlines(log_path)
matches = []
lines.each_with_index do |line, idx|
if line.match?(pattern)
start_idx = [idx - context, 0].max
end_idx = [idx + context, lines.length - 1].min
matches << {
line_number: idx + 1,
match: line.strip,
context: lines[start_idx..end_idx].map(&:strip)
}
end
end
{ matches: matches.take(20), total_found: matches.length }
end
Advanced Patterns
Combining Tools and Resources
Often you'll use both together:
# RESOURCE: Claude reads schema to understand the database
server.register_resource(
uri: "schema://users",
name: "Users Table Schema",
mime_type: "application/json"
) do
columns = User.columns.map { |c| { name: c.name, type: c.type, null: c.null } }
{ table: "users", columns: columns }.to_json
end
# TOOL: Claude uses schema knowledge to construct valid queries
server.register_tool(
name: "find_users",
description: "Query users. Check the users schema resource first to see available fields.",
input_schema: {
type: "object",
properties: {
field: { type: "string" },
value: { type: "string" },
operator: { type: "string", enum: ["equals", "contains", "greater_than", "less_than"] }
},
required: ["field", "value"]
}
) do |input|
field = input[:field]
# Validate field exists (security)
valid_fields = User.column_names
unless valid_fields.include?(field)
next { error: "Invalid field. Valid fields: #{valid_fields.join(', ')}" }
end
query = case input[:operator]
when "contains"
User.where("#{field} ILIKE ?", "%#{input[:value]}%")
when "greater_than"
User.where("#{field} > ?", input[:value])
when "less_than"
User.where("#{field} < ?", input[:value])
else
User.where(field => input[:value])
end
{ users: query.limit(10).as_json(only: [:id, :name, :email, :created_at]) }
end
Resource Templates
Some MCP implementations support dynamic resources with URI templates:
# Dynamic resource based on date
server.register_resource_template(
uri_template: "logs://daily/{date}",
name: "Daily Log",
description: "Log file for a specific date (YYYY-MM-DD format)"
) do |params|
date = Date.parse(params[:date])
log_path = Rails.root.join("log/production-#{date}.log")
File.exist?(log_path) ? File.read(log_path) : "No logs for #{date}"
end
Subscriptions (Advanced)
Resources can support subscriptions for real-time updates:
server.register_resource(
uri: "metrics://realtime",
name: "Real-time Metrics",
subscribe: true # Enable subscriptions
) do
{
active_users: User.where("last_seen_at > ?", 5.minutes.ago).count,
pending_orders: Order.pending.count,
error_rate: calculate_error_rate
}
end
When subscribed, the client receives updates when the resource changes.
Common Mistakes
Mistake 1: Using Resources for Actions
# WRONG: Resources shouldn't modify state
server.register_resource(uri: "action://send-email") do
Mailer.send_newsletter! # DON'T DO THIS
"Email sent"
end
# RIGHT: Use a tool for actions
server.register_tool(name: "send_newsletter", ...) do |input|
Mailer.send_newsletter!
{ success: true }
end
Mistake 2: Using Tools for Static Data
# WRONG: Overkill for static data
server.register_tool(
name: "get_schema",
input_schema: { type: "object", properties: {} }
) do |input|
File.read("db/schema.rb")
end
# RIGHT: Resources are simpler for static data
server.register_resource(uri: "file:///db/schema.rb") do
File.read("db/schema.rb")
end
Mistake 3: Forgetting Resources Need URIs
# WRONG: Not a valid URI
server.register_resource(uri: "my logs") # Will fail!
# RIGHT: Use proper URI format
server.register_resource(uri: "file:///logs/app.log")
server.register_resource(uri: "custom://my-logs")
Mistake 4: Returning Objects Instead of Strings for Resources
# WRONG: Resource content should be a string
server.register_resource(uri: "data://users") do
User.all.to_a # Returns Ruby objects!
end
# RIGHT: Convert to string (JSON, YAML, etc.)
server.register_resource(uri: "data://users", mime_type: "application/json") do
User.all.as_json.to_json
end
Summary
| Aspect | Tools | Resources | |--------|-------|-----------| | Purpose | Execute actions | Provide data | | Invocation | Called by Claude | Read by Claude | | State | Can modify | Read-only | | Ruby analogy | Methods/Service Objects | attr_reader/Constants | | Input | Parameters via schema | None (URI identifies it) | | Output | Structured result | Content string | | Use for | CRUD, API calls, calculations | Logs, schemas, docs, config |
Quick Decision Guide
- Does it DO something? β Tool
- Does it just PROVIDE data? β Resource
- Need parameters? β Probably a Tool
- Static reference material? β Resource
- Unsure? β Start with a Tool (more flexible)
Next Steps
- Building Your First MCP Server: Get started with Ruby MCP
- Rails ActiveRecord Integration: Connect your database to Claude
- Testing with RSpec: Write tests for your tools
- Stdio vs. SSE Transport: Deploy to production