Why Your AI Agent Makes 40 Calls to Answer One Question

MCP

Someone asked our assistant a reasonable question:

Give me a report of my tasks for this week, split into In progress and Done.

It answered correctly. It also took about forty seconds and thirty-odd tool calls to do it, because it listed the tasks, then opened each task's history one at a time to work out when the task had moved and who moved it.

That is the N+1 problem. Every backend engineer has met it in an ORM: you load a list, then fire one query per row because the thing you actually needed was not in the list. It is the same shape here, with a slower and more expensive query engine — a language model — sitting where the loop used to be.

The agent was not being stupid. Our API gave it no other way to answer.

The agent can only ask what you exposed

A model with tools does something close to query planning. It reads your tool descriptions, works out which calls could produce the answer, and executes them. If the axis it needs is not a filter, it will reconstruct that axis by hand — by fetching rows and inspecting them.

Our list_tasks could filter by assignee, by status, by column, by label, by any custom property, and by a creation date range. What it could not do was answer when was this finished. The finish time existed, in a column called resolvedAt, indexed and ready. It simply was not a filter, and it was not in the response.

So "what did I finish this week" decomposed, from the model's point of view, into: list the closed tasks, then open each one's history and look for the move. One call per task. The information was one WHERE clause away.

Three questions worth asking about every tool

We have since audited our tools with three questions. They are worth stealing.

1. Can the caller filter on every axis the data has?

Not "every field" — every axis someone will ask about. Time is almost always one, and almost always more than one: created, updated, finished, last touched. Each is a different question. A tool that exposes only createdAt forces anyone asking about the other three to page through rows.

Our fix was two parameters, resolvedFrom and resolvedTo. Four lines of server code. It removed an entire class of N+1 from every report an agent writes.

2. Does the tool know who is calling it?

"My tasks" is the most common word in these requests, and for a long time our agent could not answer it without guessing. get_current_project returned the project's members — names, roles, ids — but never said which one of them this token acts as.

Watch what an agent does with that. It looks at the member list, sees "Alex Rivera", remembers the user said "my", and picks the one whose name looks closest to something it saw earlier. That works until a team has two people called Alex.

We now return a viewer object: the user id, the name, and the role of the member the token belongs to. Identity should not be an inference.

3. Does the list return what the detail returns?

If get_task includes a field and list_tasks omits it, you have built a fetch loop. The agent gets twenty rows, needs that field for all twenty, and calls the detail endpoint twenty times.

Ours was missing resolvedAt and updatedAt on the list items while the detail carried both. Adding them is a two-line change that deletes twenty round trips.

The trap: a timestamp that lies

While adding the filters, we nearly shipped a fourth one — updatedFrom / updatedTo on updatedAt — because it is the obvious way to ask "what changed this week".

It would have been actively harmful, and the reason generalises.

updatedAt on our task table is maintained by the ORM: any UPDATE to the row bumps it. But our tasks keep status, assignee, priority, labels and due date in a separate values table. So:

  • assigning a task does not move updatedAt;

  • changing its priority does not move it;

  • moving it between two open columns does not move it;

  • a stranger upvoting it on your public portal does.

A filter built on that column would have answered "what did I work on this week" with a list that excluded the work and included a stranger's click. Worse, it would have looked plausible — the failure mode of a bad tool is not an error, it is a confident wrong answer.

We left it out, and said so in the tool description: updatedAt is a row-touch stamp, not an activity stamp. A tool description is documentation the model actually reads. Use it to rule things out, not only to explain them.

What it costs to get this wrong

The agent-facing version of a slow endpoint is not a slow page. It is:

  • Latency the user feels. Thirty sequential round trips, each one waiting for a model to decide on the next.

  • Context window burned. Every response is tokens. Thirty task histories can crowd out the thing the user actually asked for.

  • Money. Those are billed tokens, every time anyone asks.

  • Silent truncation. An agent that runs out of room stops early and summarises what it has, and its answer does not say "I only read eleven of the forty tasks".

That last one is why this is a correctness problem, not a performance problem.

The same report, now

With the three fixes in place, the weekly report is:

  1. get_current_project — who am I, and what are the columns;

  2. list_tasks with assigneeId + the In Progress column;

  3. list_tasks with assigneeId + status: "closed" + resolvedFrom.

Three calls, flat, regardless of how many tasks come back. Every row already carries resolvedAt, so grouping by day needs nothing further.

One caveat we document rather than hide: resolvedAt records when a task last entered a closed column. Reclassify a column from open to closed and every task in it gets stamped with that moment, so a week's report can pick up work that was really finished months earlier. Agents cannot infer caveats like that. Write them down where the model will read them.

If you are building an MCP server

Ask a real agent a real question and watch the call log. Not the happy path you designed — the question a user would actually type, in their words, on a Friday afternoon.

Every loop you see is an axis your API did not expose. The fix is usually a WHERE clause, a field on a list response, or a sentence in a description. It is much cheaper than the model's workaround.


BugDump's MCP server is free on every plan, including Free. Point Claude Code at your project and ask it for your week — see Using BugDump with Claude Code.