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:
- what the tool is called
- what it does
- what arguments it accepts
That is why every class declares:
tool_namedescriptionmcp_domainmcp_actioninput_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
- Start every query from a scoped root, never from an unscoped model class.
- Return IDs in every list response.
- Keep input schemas tight and explicit.
- Prefer one mutation tool per action instead of a generic
update_*tool with many modes. - Return JSON for structured data and plain text for concise confirmations.
- Keep tool class bodies short enough to read in one screen.
- 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.