An MCP-backed CLI answers from memory: in non-interactive mode the tools are not allowed
Symptom
I had an LLM CLI that supports MCP servers wired up to a small local server exposing a couple of tools over stdio. Interactively it did exactly what it should: I asked a question, the assistant called the tool, and the answer came back built out of real data from my machine.
Then I moved the same prompt into a cron-style script — one-shot, non-interactive, the -p "..." form that
prints an answer and exits. And the script worked. That was the problem.
The output was fluent, on-topic, correctly formatted, and wrong. It was an answer assembled from the model’s
own prior knowledge about what such a tool usually returns, not from what my tool actually returned. There
was no error message, no warning, no note that a capability had been withheld. The exit code was zero, so
every guard in the script — set -e, an if on the return status, a retry wrapper — was perfectly happy.
That is the worst failure mode a scheduled job can have. A crash is loud and someone fixes it. A plausible answer produced without touching the data source gets written to a file, mailed out, or committed, and it looks exactly like the correct one. I only noticed because a number in the output was stale in a way that real data could not have been.
What it is not
The reflex, once you suspect the tool was never called, is to go and debug the MCP server. Do not — or at least, not first, because everything you will find there looks fine.
It is not a dead server. The server started, and I could confirm that in the interactive session seconds later using the same configuration file, the same working directory, the same user. A server that is broken is broken in both modes. Mine was broken in exactly one.
It is not a config path problem. Config resolution does not change between interactive and one-shot runs; if the CLI could not find or parse the server definition, it would fail interactively too. The cheap check is to start the interactive session from the directory the script runs from, so that any project-local config resolves identically, and watch the tool call happen anyway.
It is not the model. I had not switched models, and a model that can drive the tool in one invocation can drive it in the next.
The one variable that actually changed was the presence of a human at the terminal. That is the thread to pull.
What I measured
In interactive mode, a tool call is a moment where the CLI can stop and ask: may I run this? You approve it, or you approve it for the session, and execution continues. That approval step is the entire safety model for anything the agent can do on your behalf.
In non-interactive mode there is nobody to ask. The CLI cannot block a scripted run on a prompt that will never be answered, so it resolves the question the only safe way it can: by defaulting to a restrictive tool policy. Tools that would have required a permission prompt are not offered to the model at all. They are not merely denied at call time — they are absent from the capability list the model sees.
From the model’s point of view nothing is wrong. It was asked a question and given no means to look anything up, so it did what any language model does with a question it cannot look up: it answered from what it knows. Nothing in that chain is an error condition, which is precisely why nothing in that chain reports one.
That also explains the shape of the bad output. It was not garbled or refusing — it was a smooth, generic, statistically average answer. The absence of a tool does not make the text look tool-less.
The fix
Name the tools explicitly in the invocation. The allow-list flag takes fully-qualified MCP tool names, and the naming shape is server and tool joined with double underscores:
llm-cli -p "summarise today's entries" \
--allowedTools "mcp__myserver__list_entries,mcp__myserver__get_entry"
Two things about that string are worth more attention than they usually get.
First, the double underscore is a separator, not decoration: mcp__<server>__<tool>. The server segment is
the name you gave the server in your MCP configuration, not the command that launches it, and the tool
segment is the name the server advertises — not your mental label for it. If you are not certain of either,
list the tools in an interactive session and copy the names verbatim.
Second, and this is the part that will cost you an hour: a typo in the allow-list does not error. An entry that matches no registered tool simply matches nothing. You are then back to the original behaviour — no tools offered, a confident answer, exit code zero — with the added handicap that you now believe you fixed it. The allow-list is something to verify, never to assume.
How to verify instead of trusting
The final text is identical whether or not the tool ran. So do not read the final text. Ask the CLI for structured output and inspect the event stream:
llm-cli -p "summarise today's entries" \
--allowedTools "mcp__myserver__list_entries" \
--output-format stream-json
In the stream, a real tool invocation appears as its own event, of type tool_use, before the assistant’s
text. A run that contains no tool_use event did not use your tool, whatever the prose claims. That is the
whole test, and it is the only reliable one I found.
Because it is a single predicate over the output, it belongs in the script as an assertion rather than in your head as a habit:
out=$(llm-cli -p "$PROMPT" \
--allowedTools "mcp__myserver__list_entries" \
--output-format stream-json)
if ! printf '%s' "$out" | grep -q '"tool_use"'; then
echo "no MCP tool call in run; refusing to publish" >&2
exit 1
fi
Fail the job rather than publish an answer that was invented. An unanswered question is a normal operational state; a fabricated answer that entered your pipeline is not.
A second failure that looks the same
Before you spend an afternoon on tool policy, rule out the other cause with an identical presentation: an expired login session.
When the CLI’s OAuth session has expired it says so — it reports an authentication failure and asks you to log in again. Interactively you cannot miss it. In a piped, scripted context that message goes to a stream nobody is reading, or gets swallowed by the redirection in your job, and what lands in your output file is again empty or generic text. Same silence, same zero-ish appearance of normality, completely different fix.
So the first diagnostic in any script that shells out to an agentic CLI is a trivial prompt:
llm-cli -p "say OK"
If that does not produce a sane answer, stop: you have an authentication problem, not a tools problem, and every minute spent on allow-lists is wasted. This costs one second and saves the afternoon.
This is the second time MCP tooling has failed on me by degrading quietly rather than reporting anything — the SDK pin that broke a stdio MCP server was the other, and it also presented as “the tool is just not there” rather than as an error at the point of use.
What to remember
Non-interactive mode is not a different input source. It is a different security context. Any capability that is gated behind a human decision is, by construction, unavailable when there is no human — and the gating is usually implemented as withhold the capability, not as fail loudly. That is a defensible default. It is simply not one you can discover by reading the output.
The general rule I took away: when you automate an agentic CLI, treat “did it actually use the tool” as a test assertion, at the same level as an HTTP status code. You would not parse a JSON body to decide whether a request succeeded; you would check the status first. The event stream is that status line, and the prose is the body. Check the one that cannot lie to you.