Sythra

Article

MCP and tool calling: designing tools an LLM can use

Tool calling fails at the interface, not the model. Schema design, descriptions, error contracts, and what MCP actually standardises.

vaibhavkothari· Aug 29, 2026· 11 min readAdvanced
ShareXLinkedIn
MCP and tool calling: designing tools an LLM can use cover

Tool calling looks solved from the outside. You describe a function, the model emits arguments, you run it, you return the result. Two hours later it works.

Then it meets real traffic and you discover the actual failure modes: the model picks search_orders when it needed get_order, passes "last week" where you wanted an ISO date, silently truncates a 900-row result into a confident wrong answer, and retries a permission error until your budget alarm fires.

None of those are model failures. They are interface design failures. This article is about the interface.

What actually happens in a tool call

The mechanics are worth being precise about, because a lot of confusion comes from imagining the model executes anything. It does not.

1. You send: messages + tool definitions (name, description, JSON schema)
2. Model returns a tool_use block: {name, input, id}   stop_reason = "tool_use"
3. YOUR code executes the function
4. You send back a tool_result block with the same id
5. Model continues, possibly calling more tools

The tool definitions are just text in the prompt. The model is doing structured prediction over your descriptions and schemas. The quality of that text is the quality of your tool calling — which is why description writing is engineering, not documentation.

import anthropic, json

client = anthropic.Anthropic()

TOOLS = [{
    "name": "get_order",
    "description": "Fetch one order by its exact ID. Returns full detail "
                   "including line items and shipment events. Use when the "
                   "user names a specific order.",
    "input_schema": {
        "type": "object",
        "properties": {"order_id": {"type": "string",
                                    "description": "e.g. ORD-4417"}},
        "required": ["order_id"],
        "additionalProperties": False,
    },
    "strict": True,
}]

messages = [{"role": "user", "content": "What happened with ORD-4417?"}]

while True:
    resp = client.messages.create(
        model="claude-opus-5", max_tokens=8000,
        thinking={"type": "adaptive"},
        tools=TOOLS, messages=messages,
    )
    messages.append({"role": "assistant", "content": resp.content})

    if resp.stop_reason != "tool_use":
        break

    results = []
    for block in resp.content:
        if block.type != "tool_use":
            continue
        # Always parse tool input as JSON — never string-match the serialised form
        args = block.input
        results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": run_tool(block.name, args),
        })
    # All results for one assistant turn go back in ONE user message
    messages.append({"role": "user", "content": results})

Two details in that loop cause real bugs when ignored. All tool results from one assistant message must go back in a single user message — split them and the model learns to stop issuing parallel calls. And tool inputs must be parsed as JSON, never matched as strings; escaping of Unicode and forward slashes varies between models.

Schema design: constrain everything you can

Every degree of freedom in your schema is a chance for the model to be creative in a way you did not want.

# Weak: three ways to be wrong
{
    "properties": {
        "status": {"type": "string"},              # "shipped"? "SHIPPED"? "in transit"?
        "since": {"type": "string"},               # "last week"? "2026-01-05"? "5 Jan"?
        "limit": {"type": "integer"},              # 1000000?
    }
}

# Strong: the schema does the teaching
{
    "properties": {
        "status": {
            "type": "string",
            "enum": ["pending", "shipped", "delivered", "cancelled"],
        },
        "since": {
            "type": "string",
            "format": "date",
            "description": "ISO 8601 date, e.g. 2026-01-05. "
                           "Resolve relative dates before calling.",
        },
        "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
    },
    "required": ["status"],
    "additionalProperties": False,
}

Rules that repay themselves immediately:

  • enum over free strings wherever the value set is closed. This eliminates an entire failure class.
  • additionalProperties: false plus strict: true guarantees the arguments validate. Without it you are writing defensive parsing code forever.
  • Bound your numbers. maximum: 100 on a limit is the difference between a slow query and an outage.
  • Descriptions on individual fields, not only on the tool. Field descriptions are where you put the format rules.
  • Flat over nested. Deeply nested argument objects produce more malformed calls than flat ones. If you have three levels, flatten them.

Descriptions: write for a competent new colleague

This is the highest-leverage text in the whole system, and it is usually one terse line.

# What people write
"description": "Searches the knowledge base."

# What actually works
"description": (
    "Full-text search over internal engineering docs and runbooks. "
    "Returns up to 10 passages with title, URL and a 300-word excerpt. "
    "Best for 'how does X work' and 'what is our policy on Y'. "
    "Does NOT cover customer data, incidents after today, or code — "
    "use search_code for code and get_incident for incidents. "
    "Prefer specific technical terms over full sentences as the query."
)

Four things every description should carry:

  1. What it returns, including shape and size. The model plans differently for 10 passages than for one record.
  2. When to use it, with example question shapes.
  3. When NOT to use it, naming the tool that should be used instead. This is the single most effective fix for selection errors.
  4. Any non-obvious input convention.

If two tools could plausibly serve the same request, each description must say why it is the wrong one — and name the right one. Selection errors are almost always missing negative guidance.

Result design: bounded, informative, honest about truncation

The result is prompt text. Treat it with the same care.

def search_docs(query: str, limit: int = 10) -> str:
    hits = index.search(query)
    shown = hits[:limit]

    if not hits:
        # An empty result must say WHY, or the model retries the same query
        return ("No matches. The index covers engineering docs and runbooks "
                "only. Try broader terms, or use search_code for source code.")

    body = "\n\n".join(
        f"[{h.title}]({h.url})\n{h.excerpt[:900]}" for h in shown
    )
    if len(hits) > limit:
        body += f"\n\n({len(hits) - limit} more matches not shown — narrow the query.)"
    return body

Three principles:

Bound the size. An unbounded result destroys the context window in one call. Cap it, and cap the per-item excerpt too.

Say when you truncated. Without that line the model treats ten of nine hundred results as the complete picture and reasons confidently from it. This is one of the most common causes of wrong-but-plausible agent output.

Make empty results explain themselves. "No results" invites an identical retry. "No results — this index does not cover X, try Y" redirects.

The error contract

Errors are information, not exceptions. The model recovers from what it can read.

def tool_result(tool_use_id, content, is_error=False):
    return {"type": "tool_result", "tool_use_id": tool_use_id,
            "content": content, "is_error": is_error}


def run(block):
    fn = TOOLS.get(block.name)
    if fn is None:
        return tool_result(block.id, f"Unknown tool {block.name}. "
                                     f"Available: {list(TOOLS)}", is_error=True)
    try:
        return tool_result(block.id, fn(**block.input))
    except ValidationError as e:
        return tool_result(block.id,
            f"Invalid arguments: {e}. Fix and retry.", is_error=True)
    except RateLimitError as e:
        return tool_result(block.id,
            f"Rate limited, retry after {e.retry_after}s. "
            f"Consider batching.", is_error=True)
    except PermissionError:
        return tool_result(block.id,
            "Permission denied. This will not succeed on retry — "
            "do not call this tool again for this request.", is_error=True)

The classification is the point. Retryable errors should say how to fix them. Terminal errors should say "do not retry." Models follow that instruction, and the difference between the two lines is the difference between a self-correcting agent and one that burns your budget in a loop.

Never drop a failed tool result. Every tool_use block needs a matching tool_result, error or not.

What MCP actually is

MCPModel Context Protocol: a standard wire format for exposing tools, resources and prompts to any LLM client, so the same server works everywhere does not change any of the above. It standardises how tools are discovered and transported, not how they should be designed.

Without it, every integration is bespoke: your tool definitions live in your application, coupled to one framework, and the next application reimplements them.

Before MCP                          With MCP
app A --> its own github tools      app A --+
app B --> its own github tools      app B --+--> MCP github server
app C --> its own github tools      app C --+
(three implementations)             (one server, three clients)

An MCP server exposes three kinds of thing:

  • Tools — functions the model can call. Same schema and description discipline as above.
  • Resources — data the client can read (files, records) addressed by URI.
  • Prompts — reusable prompt templates the user can invoke.
# A minimal MCP server
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")


@mcp.tool()
def get_order(order_id: str) -> str:
    """Fetch one order by exact ID. Returns line items and shipment events.

    Use when the user names a specific order like ORD-4417.
    For open-ended queries use search_orders instead.
    """
    order = db.get(order_id)
    if order is None:
        return f"No order {order_id}. IDs look like ORD-1234."
    return order.to_markdown()


if __name__ == "__main__":
    mcp.run()

The docstring becomes the description and the type hints become the schema — which means every design rule above still applies, just expressed in Python.

What MCP does not solve: it does not make bad descriptions good, it does not bound your results, and it does not give you an error contract. A badly designed MCP server is a badly designed tool surface that is now easy to distribute.

Connecting one to a model call requires both halves of the configuration — the server and a toolset entry naming it:

resp = client.beta.messages.create(
    model="claude-opus-5",
    max_tokens=8000,
    betas=["mcp-client-2025-11-20"],
    mcp_servers=[{"type": "url", "url": "https://mcp.example.com/orders",
                  "name": "orders"}],
    tools=[{"type": "mcp_toolset", "mcp_server_name": "orders"}],
    messages=messages,
)

Declaring mcp_servers without the matching mcp_toolset entry is rejected as a validation error — a common first-run stumble.

Scaling past ten tools

Tool definitions are prompt tokens, and they are in the cached prefix. Fifty tools is a lot of tokens on every request and a lot of near-neighbours for the model to disambiguate.

Two mechanisms help:

Tool search — mark most tools defer_loading: true and add a search tool. The model searches for the tool it needs, and only that definition is loaded.

tools = [
    {"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"},
    {**GET_ORDER, "defer_loading": True},
    {**SEARCH_ORDERS, "defer_loading": True},
    # ... 40 more deferred
    CORE_TOOL,                                # at least one must NOT be deferred
]

At least one tool must stay non-deferred, and the search tool itself must never be deferred — otherwise the request is rejected.

Namespacing and grouping. github_create_issue and jira_create_issue are easier to disambiguate than create_issue and create_ticket. Consistent prefixes act as a routing hint before the model even reads the description.

Testing tool calling

The thing most teams never build, and the thing that catches regressions.

CASES = [
    ("What happened with ORD-4417?",       "get_order",     {"order_id": "ORD-4417"}),
    ("Any orders stuck in transit?",       "search_orders", {"status": "shipped"}),
    ("Who is on the platform team?",       "find_users",    {"team": "platform"}),
    ("What is the weather in Delhi?",      None,            None),  # must not call
]


def test_selection():
    for prompt, expected_tool, expected_args in CASES:
        resp = client.messages.create(
            model="claude-opus-5", max_tokens=2000,
            tools=TOOLS, messages=[{"role": "user", "content": prompt}],
        )
        calls = [b for b in resp.content if b.type == "tool_use"]

        if expected_tool is None:
            assert not calls, f"{prompt!r} wrongly called {calls[0].name}"
            continue

        assert calls, f"{prompt!r} called no tool"
        assert calls[0].name == expected_tool
        for key, val in expected_args.items():
            assert calls[0].input.get(key) == val, calls[0].input

Twenty cases like this run in under a minute and catch the regression where adding an eleventh tool quietly breaks selection for the third. The negative cases — prompts where no tool should fire — are the ones people forget and the ones that catch over-eager tool use.

Frequently asked questions

Why does the model call the wrong tool?

Almost always because two descriptions overlap and neither says when not to use it. Add explicit negative guidance naming the correct alternative — "do not use for X, use other_tool" — to both descriptions. That single change fixes most selection errors.

Should I use MCP or define tools directly?

Define them directly if they are used by one application. Use MCP when the same tools are consumed by several clients, or when you are integrating someone else's server. MCP is a distribution and discovery standard; it does not change how you design the tools.

How many tools is too many?

Beyond ten to fifteen, selection accuracy starts to suffer and the definitions consume real prompt budget. Past that, use tool search with deferred loading, or split into several agents with focused tool sets.

What is strict tool use?

Setting strict: true on the definition, with additionalProperties: false and explicit required fields in the schema, guarantees the arguments the model produces validate exactly against your schema. It removes a whole class of runtime parsing errors and costs nothing.

Should tool errors raise exceptions?

No. Return them as tool results with is_error: true and a message the model can act on. Distinguish retryable errors (say how to fix) from terminal ones (say "do not retry"). A raised exception kills the loop; a readable error usually gets corrected on the next turn.

Next reading on Sythra Articles

Keep reading

Related articles

Newsletter

Get new articles

Free essays on learning, Python, and ML — no account required. We’ll only email when there’s something worth reading.