Tool Patterns

What Makes A Good MCP Tool In This Codebase

The implementation in this repository follows a simple rule: one tool, one job.

Every tool class should tell the model three things clearly:

  1. what the tool is called
  2. what it does
  3. what arguments it accepts

That is why every class declares:

  • tool_name
  • description
  • mcp_domain
  • mcp_action
  • input_schema

Base Structure

class Mcp::Tools::Example::List < Mcp::BaseTool
  tool_name "list_examples"
  mcp_domain "examples"
  mcp_action "read"
  description "List examples scoped to the current tenant"
  input_schema(
    properties: {
      page: { type: "integer", description: "Page number" }
    }
  )

  def self.call(page: 1)
    records = organization.examples.order(created_at: :desc)
    json_response(records.limit(25).pluck(:id, :name))
  end
end

Permission Mapping Pattern

The builder never hard-codes tool names. It filters by permission using the domain and action declared on the tool class.

Example:

  • mcp_domain "leads"
  • mcp_action "update"

maps to the permission key:

  • leads_update

That is why the tool metadata DSL is not cosmetic. It drives which tools the client can even discover.

Read Tool Pattern

Representative live pattern from the organization MCP:

class Mcp::Tools::Leads::List < Mcp::BaseTool
  include Mcp::Tools::Concerns::Listable

  tool_name "list_leads"
  mcp_domain "leads"
  mcp_action "read"

  def self.call(status: nil, stage_id: nil, listing_id: nil, page: 1)
    scope = organization.leads.includes(:lead_pipeline_stage, :listing).order(created_at: :desc)
    scope = scope.where(status: status) if status.present?
    scope = scope.by_stage(stage_id) if stage_id.present?
    scope = scope.by_listing(listing_id) if listing_id.present?

    records = paginate(scope, page: page)
    format_collection(records, page: page) do |lead|
      stage_name = lead.lead_pipeline_stage&.name || "Unknown"
      "[ID: #{lead.id}] #{lead.email} | #{lead.full_name} | #{stage_name} | #{lead.status}"
    end
  end
end

Why this works well:

  • scope begins from organization
  • returned rows include IDs for follow-up tools
  • pagination is standardized through a concern

Safe Mutation Tool Pattern

Representative live pattern from the org MCP:

class Mcp::Tools::Reviews::Approve < Mcp::BaseTool
  include Mcp::Tools::Concerns::Approvable

  tool_name "approve_review"
  mcp_domain "reviews"
  mcp_action "update"
  description "Approve a pending review"
  input_schema(
    properties: {
      id: { type: "integer", description: "Review ID to approve" }
    },
    required: ["id"]
  )

  def self.call(id:)
    find_pending(marketplace.reviews, id, label: "review") do |review|
      approve_record(review)
    end
  end
end

Why this works well:

  • mutation tools are explicit and narrow
  • the lookup is still scoped from marketplace
  • shared approval behavior stays in one concern

Create Tool Pattern

Representative live pattern from the org MCP:

class Mcp::Tools::Listings::Create < Mcp::BaseTool
  tool_name "create_listing"
  mcp_domain "listings"
  mcp_action "create"

  def self.call(name:, tagline: nil, website_url: nil, description: nil)
    listing = marketplace.listings.build(
      name: name,
      tagline: tagline,
      website_url: website_url,
      membership: marketplace.membership,
      status: :draft
    )

    listing.description = ActionText::Content.new(description) if description.present?
    listing.save!
    json_response(id: listing.id, name: listing.name, status: listing.status)
  rescue ActiveRecord::RecordInvalid => e
    text_response("Failed to create listing: #{e.record.errors.full_messages.join(', ')}")
  end
end

Why this works well:

  • writes default to a safe state such as draft
  • validation failures are turned into model-readable text
  • output includes identifiers so the AI can continue the workflow

Admin Tool Pattern

Representative live pattern from the admin MCP:

class Mcp::Admin::Tools::CrmBulkUpdate < Mcp::Admin::BaseTool
  tool_name "crm_bulk_update"
  mcp_domain "crm"
  mcp_action "bulk"

  def self.call(organization_ids:, action:, user_id:, stage_slug: nil)
    user = User.find_by(id: user_id, admin: true)
    return not_found_response("Admin user #{user_id}") unless user

    # perform a bounded, explicit bulk action
    json_response(action: action, success_count: 0, skipped_count: 0, results: [])
  end
end

The important part is that admin tools keep the same ergonomics as org tools even though they have a different scope model.

Concern Design Rules

Formattable

Use for response construction only. Do not hide business logic inside it.

Listable

Use when many list tools need the same pagination and plain-text list rendering style.

Approvable

Use only when multiple domains share the same status transition behavior.

ToolMetadata

Keep it dumb. It should only expose the metadata needed for permission filtering.

Tool Authoring Rules For The Exported Feature

  1. Start every query from a scoped root, never from an unscoped model class.
  2. Return IDs in every list response.
  3. Keep input schemas tight and explicit.
  4. Prefer one mutation tool per action instead of a generic update_* tool with many modes.
  5. Return JSON for structured data and plain text for concise confirmations.
  6. Keep tool class bodies short enough to read in one screen.
  7. Move duplicated behavior into concerns only after at least two or three tools share it.

Builder Discovery Pattern

The current code discovers tools through namespace constants:

def self.tool_classes
  @tool_classes ||= TOOL_NAMESPACES.flat_map do |ns|
    ns.constants.map { |c| ns.const_get(c) }.select { |c| c < Mcp::BaseTool }
  end.freeze
end

This is simple and Rails-friendly, but it assumes those constants are autoloadable. If the target project has stricter boot behavior, you may want to replace namespace scans with an explicit array of tool classes.