Home Blog Contact
Home/Blog/Why Your Agent Never Calls Your MCP Tool
How toLLM EngineeringMCPAI AgentsDebugging

Why Your Agent Never Calls Your MCP Tool

11 min readBy Miloš Mitrović

When an agent silently ignores a tool you registered, the cause is almost always one of four things, and they have a fixed diagnostic order: the tool never reached the model's context, the client rejected the input schema, the name was mangled or shadowed by another server, or the description failed to distinguish your tool from a neighboring one. Check them in that order, because each later check is meaningless if an earlier one fails. Most teams start at the description (the fun part) and burn an afternoon rewriting prose for a tool the model was never shown. This is the ladder I run, top to bottom, with the exact commands at each rung.

Key takeaways

  • Prove the tool appears in a raw tools/list response before touching anything else. If it is missing there, no prompt change will help.
  • Clients cache the tool list from the initialize handshake. Tools registered after connect need a notifications/tools/list_changed and the matching listChanged capability.
  • Schema rejection is usually silent. Top-level $ref, oneOf, unsupported format values, and a missing "type": "object" are the frequent offenders.
  • Tool names get namespaced, truncated, and deduplicated by clients. Two servers exposing search is a shadowing bug waiting to happen.
  • The description is the selection signal. Write when to use it, when not to, and what it returns, then measure selection rate on a fixed prompt set instead of guessing.

Confirm the model ever saw the tool

Start by asking the server for its tool list over the wire, with no client in between. The MCP specification defines tools/list as the discovery method, and a compliant server only answers it after the initialize handshake completes (MCP spec, Tools). For a stdio server you can drive the whole exchange with three JSON-RPC lines on one pipe.

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  | node dist/server.js

Three outcomes matter. If the process errors or prints nothing, you have a transport or startup problem, not a tool problem. If the initialize result comes back without a tools key in capabilities, the client will never call tools/list at all. If the list comes back and your tool is absent, the registration code did not run (a conditional feature flag, a lazy import that threw and got swallowed, a decorator applied after the server started). Only once the tool is present in that raw JSON do you have a selection problem. For an interactive version of the same probe, the official MCP Inspector renders the list and lets you invoke tools by hand, which is covered in more depth in debugging an MCP server with MCP Inspector.

Check capability negotiation and cached tool lists

If the tool exists on the server but not in the client, suspect list caching. Capabilities are negotiated once during initialize, and the client is entitled to fetch tools/list a single time and hold it for the session (MCP spec, Lifecycle). Servers that build their tool set dynamically, for example after an OAuth token resolves or after a database schema is introspected, routinely register tools a few hundred milliseconds too late.

The fix has two halves and both are required. Declare the capability during initialize, then emit the notification when the set changes.

// server capabilities, sent during initialize
{ "capabilities": { "tools": { "listChanged": true } } }

// later, after tools are registered
server.sendToolListChanged();  // notifications/tools/list_changed

Declaring listChanged: true without ever sending the notification is worse than not declaring it, because the client stops polling. Sending the notification without declaring the capability means well-behaved clients drop it. Also check that your handler is not filtering by session state that is empty on the first request, a trap that shows up often in stateless HTTP deployments where each request may land on a different process.

Ship a schema the client will accept

A tool whose inputSchema the provider rejects usually disappears without a visible error, because the client drops it while assembling the request rather than failing loudly. Providers accept a subset of JSON Schema, not the whole draft. Anthropic's tool use documentation specifies that the top level must be an object schema with properties (Claude tool use overview), and OpenAI's structured-output mode is stricter still, requiring additionalProperties: false and every property listed in required, with optionality expressed as a nullable union (OpenAI function calling guide). Generated schemas are the usual source of trouble: Pydantic and zod-to-json-schema both emit $ref and $defs for nested models, and a top-level $ref is not an object schema as far as the client is concerned.

// rejected or silently dropped
{
  "name": "search",
  "inputSchema": {
    "$ref": "#/$defs/Query",
    "$defs": { "Query": { "type": "object", "properties": { "q": { "type": "string" } } } }
  }
}

// accepted everywhere
{
  "name": "crm_search_contacts",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "description": "Free text, an email domain, or a lifecycle stage name." },
      "limit": { "type": "integer", "minimum": 1, "maximum": 25, "default": 10 }
    },
    "required": ["query"],
    "additionalProperties": false
  }
}

Inline your definitions, drop exotic format values (uri-reference, duration) in favor of a plain string with the expected shape in the description, and avoid oneOf or allOf at the root. A useful guard in CI: serialize every tool your server exposes and assert that each schema has type: "object", no $ref anywhere, and no property description longer than your budget. The same constraints govern function arguments in general, which is why the discipline around reliable structured outputs transfers directly to tool schemas.

Rule out name collisions and renaming

Clients rewrite your tool names, and the rewrite can break the model's ability to reference the tool. Provider APIs constrain names to roughly ^[a-zA-Z0-9_-]{1,64}$, so a client that namespaces by prefixing the server name (mcp__crm__search) can push a long name past the limit and truncate it, or sanitize a dot or space into something the model has not seen in its instructions. When two connected servers both expose search or get_user, some clients deduplicate by dropping the second, and some keep both, which leaves the model choosing between two identically named tools it cannot tell apart.

Diagnose it by dumping the tool names as the model receives them, not as your server defines them. In Claude Code, claude --debug prints the assembled tool list; in a custom client, log the array you pass to the provider SDK right before the call. Then fix the source: name tools verb_object with a service prefix you own (crm_search_contacts, crm_get_contact), keep them under about 40 characters so client prefixes have room, and never reuse a bare English verb. If you run several servers side by side, this is also the moment to check which ones are even enabled, as described in adding local and remote MCP servers to Claude Code.

Write the description as the selection signal

The description is the only thing the model reads when deciding between your tool and its alternatives, so it has to carry the routing logic. Anthropic's engineering guidance on tool design is blunt about this: descriptions should read like documentation for a new team member who has no access to your codebase, with explicit input formats, return shapes, and boundaries (Writing tools for agents). A one-word description like "Search" gives the model nothing to route on, and it will fall back to whatever tool has the more specific text.

Four things belong in every description, in this order.

  1. What it does and against what data. "Search CRM contacts", not "search records".
  2. What it returns, including limits. "Up to 25 contacts with id, email, lifecycle stage. Does not include deal history."
  3. When to use it instead of the neighbor. "Use crm_get_contact when you already have a contact id."
  4. What it will not do. "Does not search companies or deals." Negative boundaries kill more wrong calls than positive examples add right ones.

Keep each description in the 50 to 150 token range. Longer ones compete with everything else in the window, and once a server exposes dozens of tools the definitions alone can dominate the prompt, a degradation pattern discussed in context rot and answered structurally in tool search versus code execution. Parameter descriptions matter as much as the tool description: a query field with no description means the model guesses at whether it accepts an email, a name, or a filter expression, and a guess that produces an empty result set trains it to stop calling the tool at all.

Rule out client-side filtering and permission gates

A tool can be present, valid, and well described, and still be gated before the model ever sees it. Check four gates. First, allowlists and deny rules in client settings, which can exclude a whole server or a single tool by pattern. Second, tool-count caps: some clients cut off the list past a threshold, and the ordering is not one you control. Third, approval flow. If a tool is annotated destructiveHint: true or lacks readOnlyHint, a client running non-interactively may skip it rather than block on a prompt that no one can answer. Annotations are hints in the spec, not enforcement, but clients treat them as policy.

server.registerTool("crm_search_contacts", {
  description: "...",
  inputSchema: { /* ... */ },
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
});

Fourth, and easiest to miss: the model called the tool, the call failed, and the failure was invisible to you. A tool result with isError: true is returned to the model rather than raised as a protocol error, which is deliberate (it lets the agent recover), but it means a tool that throws on every invocation looks like a tool that is never invoked unless you log server side. Log every tools/call with its arguments and outcome, and check for a run of first attempts followed by silence. That pattern says the model tried once, got an error, and gave up. Repeated identical calls with no progress say something else, and the fix there is making the tool idempotent for retrying agents.

Measure selection with a fixed prompt set

Once the mechanical checks pass, stop reasoning about descriptions in the abstract and measure. Build 20 to 50 prompts covering the cases your tool should win, the cases a neighboring tool should win, and the cases where no tool should fire, then score which tool the model picks and whether the arguments validate against the schema. Tool-selection accuracy has been benchmarked this way since the Gorilla work on API-calling models, which showed selection errors and argument hallucination as distinct failure modes needing separate measurement (Gorilla, arXiv:2305.15334).

CASES = [
  ("find everyone at acme.com",            "crm_search_contacts"),
  ("what stage is contact 8812 in",         "crm_get_contact"),
  ("how much revenue did we book in June",  None),  # no tool should fire
]

for prompt, expected in CASES:
    resp = client.messages.create(model="claude-sonnet-5", tools=TOOLS,
                                  max_tokens=1024,
                                  messages=[{"role": "user", "content": prompt}])
    picked = next((b.name for b in resp.content if b.type == "tool_use"), None)
    assert picked == expected, f"{prompt!r}: got {picked}, want {expected}"

Render the results as a confusion matrix over tool names. The off-diagonal cells tell you exactly which pair of descriptions overlaps, and that is the pair to edit. Change one description, rerun, keep the change only if the matrix improves. Ten iterations of this take an hour and beat any amount of prompt intuition. Run it in CI too, because adding a fifteenth tool to a server is the standard way to break selection for the previous fourteen.

Trade-offs I would make

Detailed descriptions and small tool counts pull against each other, and the honest resolution depends on how many tools you expose. Under about 15 tools, spend the tokens: 100 to 150 token descriptions with explicit boundaries pay for themselves in avoided wrong calls, and the total tool-definition overhead stays around 2k tokens. Past 30 tools, description quality stops being the lever, because the model is now doing retrieval over a long list rather than choosing between a few options. At that point consolidate related tools behind fewer entry points with an enum parameter, or move to a search-then-load pattern, and accept the extra round trip.

On schemas, I would rather be stricter than the loosest client requires. Inlined definitions, additionalProperties: false, and explicit enums cost nothing at runtime and eliminate an entire class of silent drops when a user connects your server to a client you did not test against. The counter-argument is that strict schemas make optional arguments awkward (nullable unions instead of omitted keys), and that is a genuine cost I accept.

On naming, always namespace, even for a server you think will run alone. The prefix costs a handful of tokens per tool and prevents a collision class that is painful to diagnose from the outside, since the symptom shows up in someone else's client with a server you have never seen. The one thing I would not do is optimize descriptions before proving the tool reaches the model. That ordering is the whole point of the ladder.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.