MCP tool descriptions are the API: lessons from a Rails server


The bug that wasn’t a bug

An agent was asked to mark a sales deal as lost. The deal had been actively pursued: discovery call, signed NDA, demo, formal proposal. The client politely declined on price. Textbook closed-lost.

The agent reached for a tool called disqualify_lead, set the reason to “other,” and moved on.

Technically the call succeeded. Functionally it was wrong. In this CRM’s domain, disqualify was reserved for spam and junk leads that never belonged in the pipeline. It archived the record into a hidden bucket. The deal vanished from the board, the “Lost” column stayed empty, and every subsequent get_lead, update_lead, and delete_lead on that record returned a bare 404. The record existed. The tools just couldn’t see it.

No exception was raised. No test failed. The server did exactly what it was told.

The failure was entirely in how the tools described themselves to the model.

The thesis

When you build an MCP server, your tool names, descriptions, and JSON schemas are not documentation. They are the API surface the LLM programs against.

Treat them with the same rigor you’d give a public REST API — arguably more, because your caller is a probabilistic reasoner, not a developer reading your source. The model can’t open your code, can’t ask a teammate, can’t infer your intent from a Slack thread. It only has the schema and the description. That’s the whole contract.

Everything below is what I’d codify after that debugging session, with Ruby and Rails in mind.

1. Model independent state as separate fields, not one overloaded enum

The root cause wasn’t the agent. It was a data model that smushed several orthogonal concerns into a vague notion of “status.” A lead in our CRM actually had four independent axes:

  • stagelead → pre_qualified_lead → marketing_qualified_lead → sales_qualified_lead → prospect
  • status (stage-specific) — e.g. a prospect is negotiating / won / lost / on_hold
  • archived (boolean) — hidden from active views
  • disqualified (boolean) — never belonged in the pipeline (spam/junk/duplicate)

The distinction the agent missed: won/lost are outcomes of a pursued deal; disqualified is for records that never belonged. Different axes, not different values of one field.

In Rails, lean on enum so the allowed values live in one place and are introspectable:

class Prospect < ApplicationRecord
  enum :status, {
    negotiating: "negotiating",
    won: "won",
    lost: "lost",
    on_hold: "on_hold"
  }, validate: true

  # archived / disqualified are separate boolean columns — on purpose.
  # Don't fold them into `status`.
end

If you find yourself adding a status value like archived_lost_spam, stop. You’re collapsing axes that want to stay independent.

2. Write when_to_use and not_for on every tool

The single highest-leverage fix from that session was adding two fields to every tool description: when to use it, and when not to. The model picks tools by reading their descriptions. If two tools sound plausible, it will sometimes pick the wrong one. The “not for” line is where you cut off the wrong one explicitly.

class DisqualifyLeadTool < MCP::Tool
  description <<~DESC
    Disqualify a lead and remove it from the active pipeline.

    when_to_use: ONLY for records that never belonged in the pipeline —
    spam, scam, junk, not-a-fit, duplicate, or invalid contact.

    not_for: a deal that was actively pursued and lost. For that,
    set status to 'lost' via set_outcome with a win/loss reason.

    side_effect: also sets archived=true. Reversible via restore_lead.
  DESC
end

That “not_for” line alone would have prevented the entire incident. Write the negative space explicitly. The model can’t infer your intent from a verb like disqualify; it can only read what you wrote.

Every MCP tool should answer: what does this tool do, when should the agent use it, what is it explicitly not for, what are the side effects, is the action reversible.

3. Put enums in the schema — never leave them to free text

Our original update_lead accepted status as a free-form string. The model had no idea lost was even a legal value, so it guessed a different tool entirely. Put the enum in the JSON Schema the tool exposes, and generate it straight from the model so it can never drift:

class UpdateLeadTool < MCP::Tool
  input_schema(
    properties: {
      stage:  { type: "string", enum: Lead.stages.keys },
      status: {
        type: "string",
        enum: Prospect.statuses.keys,
        description: "Stage-specific. Prospect: negotiating/won/lost/on_hold."
      }
    },
    required: %i[id stage]
  )
end

Generating enum: from Prospect.statuses.keys means a refactor updates both your Rails model and your MCP schema at once. Schema drift is one of those silent bugs that only shows up when an agent guesses around it.

4. Document the domain model at server initialization

Rails apps often encode business state across several fields, models, scopes, and callbacks. An LLM cannot infer those relationships reliably from isolated tool schemas. Tell it up front.

MCP supports a server-level instructions field that’s loaded before any tool is called. Use it for the conceptual stuff the schemas can’t carry:

INSTRUCTIONS = <<~TEXT
  Lead state has independent axes:

  - stage: where the record sits in the pipeline.
  - status: the current state inside that stage.
  - archived: whether it is hidden from the active pipeline.
  - disqualified: whether it was removed because it never belonged.

  won/lost are OUTCOMES of a deal that was actively pursued.
  Set them via set_outcome with a win/loss reason.

  disqualified means the lead should not be in the pipeline at all:
  spam, scam, junk, not-a-fit, duplicate, or invalid contact.
  Do not use disqualification for a pursued deal that was lost.
TEXT

json_rpc_result(id, {
  protocolVersion: PROTOCOL_VERSION,
  capabilities: { tools: {} },
  serverInfo: { name: @name, version: @version },
  instructions: INSTRUCTIONS
})

This is the difference between giving the agent a glossary and giving it a manual.

5. The Rails trap behind the vanishing record: default_scope

Here’s the part that’s pure Ruby. The 404 on a record that demonstrably existed had a classic cause: a default_scope silently filtering archived rows out of every query.

# The trap:
class Prospect < ApplicationRecord
  default_scope { where(archived: false, disqualified: false) }
end

# Now Prospect.find(232) raises RecordNotFound the moment it's archived.
# Every tool that does Prospect.find(id) inherits the blindness.

default_scope is seductive and treacherous. It makes the common case clean and the edge case invisible. Prefer explicit named scopes and make “include hidden” a deliberate choice:

class Prospect < ApplicationRecord
  scope :active, -> { where(archived: false, disqualified: false) }
  scope :hidden, -> { where("archived OR disqualified") }
end

def find_prospect(id, include_hidden:)
  base = include_hidden ? Prospect.unscoped : Prospect.active
  base.find(id)
end

This maps the data layer directly onto an include_hidden flag your MCP tools can expose. The model’s intent (“yes, I really mean the archived one”) flows cleanly down to the query.

6. Every hide/destroy needs an inverse and an escape hatch

The most painful part of the incident: once the record was disqualified and archived, there was no MCP path to undo it. get, update, delete all 404’d. The agent had quietly walked the data into a room with no door.

Two rules follow:

Every destructive or hiding operation needs a documented inverse. If you can disqualify, you must be able to restore. If you can archive, you must be able to reactivate. The inverse needs to be in the same tool list — discoverable in the same place the destructive one is.

class RestoreLeadTool < MCP::Tool
  description <<~DESC
    Reactivate a hidden lead by clearing archived/disqualified state
    and disqualification metadata.

    when_to_use: undo an accidental archive or disqualification, or
    return a valid record to the active pipeline.

    After restoring a pursued lost/won prospect, call set_outcome if
    its outcome still needs to be recorded.
  DESC
end

Hidden records must be reachable on purpose. Add an include_hidden: true flag to get/update/delete so an operator can touch archived rows intentionally. Default the flag to false, so the conservative read stays the default.

Design principle: if an action can hide or destroy data, the reverse action must exist and be discoverable in the same tool list.

7. Errors are UX for the model

A bare 404 told the agent nothing. It reasonably concluded the record had been deleted and started planning a recreate, which would have produced a duplicate. An error is a message to a reasoning system; make it actionable.

rescue ActiveRecord::RecordNotFound
  if Prospect.unscoped.exists?(id: params[:id])
    error!(
      "Record #{params[:id]} exists but is archived/disqualified. " \
      "Pass include_hidden: true to reach it, or call restore_lead to reactivate."
    )
  else
    error!("No record with id #{params[:id]}.")
  end
end

“Exists but hidden” and “truly gone” are completely different signals to the caller. Spend the extra query to tell them apart. The agent’s next action depends on it.

8. Name tools after intent, not after table operations

update_lead(status: "lost", reason: "...") works, but it invites the model to treat a deal outcome as a generic field edit. A dedicated, intent-named tool encodes the domain rule and is much harder to misuse:

class SetOutcomeTool < MCP::Tool
  description <<~DESC
    Set the win/loss outcome for a PURSUED prospect and store the
    win/loss reason.

    when_to_use: mark an actively pursued deal as won or lost.
    not_for: spam, scam, junk, duplicate, invalid contact, or
    not-a-fit records — use disqualify_lead for those.

    Writes status + reason. Does not touch archived/disqualified.
  DESC

  input_schema(
    properties: {
      id: { type: "integer" },
      outcome: { type: "string", enum: %w[won lost] },
      reason: {
        type: "string",
        description: "Win/loss reason for the pursued deal. " \
          "Not a disqualification reason."
      }
    },
    required: %i[id outcome reason]
  )
end

Back it with a service object so the invariants live in one place:

class SetProspectOutcome
  def call(prospect, outcome:, reason:)
    raise ArgumentError, "outcome must be won/lost" unless %w[won lost].include?(outcome)
    prospect.update!(status: outcome, reason: reason)
  end
end

Intent-named tools plus thin service objects equals the model’s choices mapping onto your domain language, with invariants that can’t be bypassed by a generic update.

9. Document side effects, and make mutations idempotent

disqualify also flipping archived: true was a hidden coupling nobody declared. State side effects in the description:

side_effect: also sets archived=true. Reversible via restore_lead.

Promotions, deletes, sends, imports, state transitions — all describe their side effects. The agent should not discover them by accident.

And while you’re at it: make mutations idempotent where you can. Agents retry. Connections drop. A tool call can land twice. A set_outcome that’s safe to call twice is a tool you never have to debug at 11pm.

10. Test the contract, not just the endpoint

Rails request specs verify endpoints work. MCP servers need a second layer of tests that verify the agent-facing contract — the names, descriptions, enums, and instructions the model actually sees.

RSpec.describe "MCP contract" do
  it "exposes recovery tools alongside destructive ones" do
    expect(tool_names).to include("disqualify_lead", "restore_lead")
    expect(tool_names).to include("set_outcome")
  end

  it "explains the domain model in server instructions" do
    expect(instructions).to include("won/lost are OUTCOMES")
    expect(instructions).to include("disqualified means the lead should not be")
  end

  it "guards destructive tools with not_for clauses" do
    expect(disqualify["description"]).to include("not_for")
    expect(disqualify["description"]).to match(/spam|junk|duplicate/i)
  end

  it "exposes valid status values as schema enums" do
    statuses = update.dig("inputSchema", "properties", "status", "enum")
    expect(statuses).to include("negotiating", "won", "lost")
  end
end

These tests protect the MCP interface from quietly regressing into ambiguity the next time someone refactors a controller.

A checklist for your next Rails MCP server

Before shipping, walk through every tool with this:

  • Independent state concerns are separate fields, not one overloaded enum
  • Every tool description has when_to_use and not_for
  • Every constrained field exposes its enum in the JSON Schema, generated from the model
  • Server instructions explain the domain model the schemas can’t carry
  • No blind default_scope — use named scopes and unscoped deliberately
  • Every hide or destroy action has a documented inverse and an include_hidden escape hatch
  • Errors distinguish “exists but hidden” from “truly gone,” with the next action to take
  • Domain operations are intent-named tools backed by service objects, not raw updates
  • Side effects and irreversibility are spelled out in the description
  • Mutations are idempotent wherever feasible
  • Contract tests assert the names, descriptions, enums, and server instructions

What I’d tell past-me

We spend enormous effort making REST APIs legible to humans — naming, docs, error messages — and then ship MCP tools with a one-line description and a free-text field. The LLM is a worse reader than a human engineer in one specific way: it can’t open your source, can’t ask a teammate, can’t infer your intent from anything you didn’t write down.

So the discipline is almost the opposite of what feels natural for a Rails dev. You don’t write the minimal description and trust the model to be smart. You write the description as if it were the prompt — because it is — and you make the wrong action either impossible or clearly labeled “not for this.”

The good news: every problem in that session was fixable, and none of the fixes were exotic Ruby. A not_for clause. An enum in a schema. A restore tool. A named scope instead of a default scope. It’s just taking the tool layer as seriously as you take the rest of your API, and remembering that your most important consumer now reasons in probabilities and reads everything you write literally.