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:

  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::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:

  • user is resolved from Mcp::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 before tools/list returns

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 Listable concern
  • 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 returns nil, not a record
  • response uses json_response so the AI gets structured data it can reason about
  • missing records get a consistent not_found_response rather 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_item so 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_by on 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

  1. Start every query from a user-scoped association, never from an unscoped model class.
  2. Return IDs in every list response. The model has no way to discover records otherwise.
  3. Keep input schemas tight. Required fields are explicit; optional fields are typed and described.
  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 short confirmations. Be consistent across a domain.
  6. Keep tool class bodies short enough to read in one screen. If they grow, extract a concern or a model method.
  7. Move duplicated behavior into concerns only after at least two or three tools share it. Premature concerns are worse than a little duplication.
  8. Permit only whitelisted attributes on create/update tools. Do not mass-assign from **args without a PERMITTED filter.

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):

  1. Create app/models/mcp/tools/projects/ and add the five CRUD files
  2. Add "Mcp::Tools::Projects" to TOOL_NAMESPACES
  3. Add "projects" to Mcp::Token::PERMISSION_KEYS
  4. Update the token form view to surface the new permission checkbox
  5. Add tests that cover the domain-specific scoping

Keep the per-domain folder structure consistent β€” it pays off when the catalog grows.