Tool Patterns
What Makes A Good MCP Tool In This Package
The generic implementation follows one rule: one tool, one job.
Every tool class tells 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::Items::List < Mcp::BaseTool
tool_name "list_items"
mcp_domain "items"
mcp_action "read"
description "List the current user's items"
input_schema(
properties: {
page: { type: "integer", description: "Page number (default 1)" }
}
)
def self.call(page: 1)
records = user.items.order(created_at: :desc)
json_response(records.limit(25).pluck(:id, :name))
end
end
Notes:
useris resolved fromMcp::BaseTool.user_contextβ the caller never passes a user id.- The query starts from
user.items, so cross-user access is structurally impossible.
Permission Mapping Pattern
The builder never hard-codes tool names. It filters by the domain declared on the tool class.
Example:
mcp_domain "items"- token
permissions["items"] == trueβ tool is visible and callable - token
permissions["items"] == falseβ tool is filtered out beforetools/listreturns
That is why the ToolMetadata DSL is not cosmetic. It drives which tools the client can even discover. Changing the domain string changes the permission key.
If you prefer permission granularity per action (items_read, items_create), extend Mcp::Token#permits? to take both domain and action, and update the builder call. The generic package uses domain-only for simplicity; the export package uses domain + action. Pick one and stay consistent.
1. List Tool Pattern β list_items
class Mcp::Tools::Items::List < Mcp::BaseTool
include Mcp::Tools::Concerns::Listable
tool_name "list_items"
mcp_domain "items"
mcp_action "read"
description "List the current user's items (newest first)"
input_schema(
properties: {
page: { type: "integer", description: "Page number (default 1)" },
status: { type: "string", description: "Optional status filter" }
}
)
def self.call(page: 1, status: nil)
scope = user.items.order(created_at: :desc)
scope = scope.where(status: status) if status.present?
records = paginate(scope, page: page)
format_collection(records, page: page) do |item|
"[ID: #{item.id}] #{item.name} | status=#{item.status}"
end
end
end
Why this works well:
- query begins from
user.items - returned rows include IDs so the model can feed them into
get_item,update_item,delete_item - pagination is standardized through the
Listableconcern - filters are optional and typed explicitly in
input_schema
2. Get Tool Pattern β get_item
class Mcp::Tools::Items::Get < Mcp::BaseTool
tool_name "get_item"
mcp_domain "items"
mcp_action "read"
description "Fetch a single item by ID"
input_schema(
properties: {
id: { type: "integer", description: "Item ID" }
},
required: ["id"]
)
def self.call(id:)
record = user.items.find_by(id: id)
return not_found_response("item") unless record
json_response(
id: record.id,
name: record.name,
status: record.status,
created_at: record.created_at
)
end
end
Why this works well:
find_by(id:)on the scoped association means a valid id for a different user returnsnil, not a record- response uses
json_responseso the AI gets structured data it can reason about - missing records get a consistent
not_found_responserather than an exception
3. Create Tool Pattern β create_item
class Mcp::Tools::Items::Create < Mcp::BaseTool
PERMITTED = %i[name status notes].freeze
tool_name "create_item"
mcp_domain "items"
mcp_action "create"
description "Create a new item for the current user"
input_schema(
properties: {
name: { type: "string" },
status: { type: "string", enum: %w[draft active archived] },
notes: { type: "string" }
},
required: ["name"]
)
def self.call(**args)
record = user.items.new(args.slice(*PERMITTED))
record.status ||= "draft"
record.save!
json_response(id: record.id, name: record.name, status: record.status)
rescue ActiveRecord::RecordInvalid => error
text_response("Failed to create item: #{error.record.errors.full_messages.join(", ")}")
end
end
Why this works well:
- writes default to a safe state (
draft) - validation failures become a model-readable text response, not a 500
- output includes the new id so the AI can continue the workflow (e.g. update, attach)
- only whitelisted attributes make it through
PERMITTED
4. Update Tool Pattern β update_item
class Mcp::Tools::Items::Update < Mcp::BaseTool
PERMITTED = %i[name status notes].freeze
tool_name "update_item"
mcp_domain "items"
mcp_action "update"
description "Update an existing item owned by the current user"
input_schema(
properties: {
id: { type: "integer" },
name: { type: "string" },
status: { type: "string", enum: %w[draft active archived] },
notes: { type: "string" }
},
required: ["id"]
)
def self.call(id:, **args)
record = user.items.find_by(id: id)
return not_found_response("item") unless record
record.assign_attributes(args.slice(*PERMITTED))
record.save!
json_response(id: record.id, name: record.name, status: record.status)
rescue ActiveRecord::RecordInvalid => error
text_response("Failed to update item: #{error.record.errors.full_messages.join(", ")}")
end
end
Why this works well:
- scoped lookup prevents cross-user updates
- partial update β only the provided attributes are assigned
- same validation-feedback shape as
create_itemso the AI learns one pattern
5. Delete Tool Pattern β delete_item
class Mcp::Tools::Items::Delete < Mcp::BaseTool
tool_name "delete_item"
mcp_domain "items"
mcp_action "delete"
description "Delete an item owned by the current user"
input_schema(
properties: {
id: { type: "integer", description: "Item ID to delete" }
},
required: ["id"]
)
def self.call(id:)
record = user.items.find_by(id: id)
return not_found_response("item") unless record
record.destroy!
text_response("Item ##{id} deleted")
rescue ActiveRecord::RecordNotDestroyed => error
text_response("Failed to delete item ##{id}: #{error.message}")
end
end
Why this works well:
find_byon the scoped association means cross-user deletes return "not found", not a successful delete- the success response is plain text β short and unambiguous for the model
- soft-delete libraries (
paranoia,discard) plug in without changing the shape of the tool
Concern Design Rules
Formattable
Use for response construction only. Do not hide business logic inside it. It should know about MCP::Tool::Response, JSON pretty-printing, and that is all.
Listable
Use when many list tools need the same pagination and plain-text list rendering. Two consistent helpers β paginate and format_collection β are usually enough.
ToolMetadata
Keep it dumb. It exposes mcp_domain and mcp_action as class-level DSL. Do not add policy logic here β the builder owns permission filtering.
Tool Authoring Rules For This Package
- Start every query from a user-scoped association, never from an unscoped model class.
- Return IDs in every list response. The model has no way to discover records otherwise.
- Keep input schemas tight. Required fields are explicit; optional fields are typed and described.
- Prefer one mutation tool per action instead of a generic
update_*tool with many modes. - Return JSON for structured data and plain text for short confirmations. Be consistent across a domain.
- Keep tool class bodies short enough to read in one screen. If they grow, extract a concern or a model method.
- Move duplicated behavior into concerns only after at least two or three tools share it. Premature concerns are worse than a little duplication.
- Permit only whitelisted attributes on create/update tools. Do not mass-assign from
**argswithout aPERMITTEDfilter.
Builder Discovery Pattern
The current generic builder discovers tools through namespace strings:
TOOL_NAMESPACES = %w[
Mcp::Tools::Items
].freeze
def self.tool_classes
TOOL_NAMESPACES.flat_map do |name|
namespace = name.safe_constantize
next [] unless namespace
namespace.constants.map { |c| namespace.const_get(c) }.select { |c| c.is_a?(Class) && c < Mcp::BaseTool }
end
end
This is simple and Rails-friendly and tolerates missing namespaces during boot. If the target project has stricter boot behavior, you can replace the namespace scan with an explicit array of tool classes.
Adding A New Domain
To add a new resource (say Project):
- Create
app/models/mcp/tools/projects/and add the five CRUD files - Add
"Mcp::Tools::Projects"toTOOL_NAMESPACES - Add
"projects"toMcp::Token::PERMISSION_KEYS - Update the token form view to surface the new permission checkbox
- Add tests that cover the domain-specific scoping
Keep the per-domain folder structure consistent β it pays off when the catalog grows.