πŸ“šguideintermediate

Understanding MCP: Tools vs. Resources (Explained for Rubyists)

Master the two core MCP primitives. Tools are like Service Objects (actions), Resources are like attr_reader (passive data). Learn when to use each.

ByMCP Directory Team
Published
⏱️20 minutes
rubymcp-conceptstoolsresourcesarchitecture

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

  1. The Ruby Analogy
  2. Tools: Service Objects for AI
  3. Resources: Passive Data Providers
  4. When to Use Which?
  5. Implementation Examples
  6. Advanced Patterns
  7. 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

  1. User asks Claude something that requires action
  2. Claude determines which tool can help
  3. Claude constructs the input based on the schema
  4. Your Ruby block executes
  5. Result is returned to Claude
  6. 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

  1. Does it DO something? β†’ Tool
  2. Does it just PROVIDE data? β†’ Resource
  3. Need parameters? β†’ Probably a Tool
  4. Static reference material? β†’ Resource
  5. Unsure? β†’ Start with a Tool (more flexible)

Next Steps