The Bug That Made Me Build a CLI
On June 8, 2026, an agent tried to detach a task from its parent on my Takt board. It sent parent="". Takt's MCP server received arguments: {}. Again and again.
A tap on the raw request body cleared the server in five minutes: the MCP client dropped the entire arguments object whenever any single value was an empty string. That was the fourth agent-facing fault in two months. Instead of a fifth workaround, Takt got a CLI that day.
I'd love to blame the protocol for all four. I can't. Two were mine: tool names that collided (create_wiki next to wiki_create), and about 40 tools that returned JSON double-encoded inside JSON. One needed a server-side workaround for a client and framework I didn't control. Only the empty-string drop was purely the client's, and it had no workaround at all, because the arguments never reached my code.
That boundary is what the CLI replaced. With taktcli, everything between the agent's arguments and my HTTP API is code I own. Parallel agents built it the same day, and taktcli login promptly broke on first real use, because the page it pointed at didn't exist yet.
I didn't start out CLI-first. When I heard Peter Steinberger on Lex Fridman's podcast say "Every MCP would be better as a CLI", and argue that MCP fills the context with huge blobs a CLI could filter with jq, I was angry. I'd just learned how to build my own MCP servers. Then I admitted how much an agent does with gh, and how noticeably slower MCP felt. I measured Takt's MCP tool list and was shocked by its size.
So I built a theory. MCP has to stay small, or it floods the context with noise, and small means less functionality. A CLI wins by design. In October I tested it, and the evidence agreed with me about the cost and disagreed about the cause.
The Numbers Looked Like a CLI Victory
The first results looked like a landslide. Every shipped MCP server took 3-6x the calls of the GraphQL CLI baseline, on both services and both models. On GitHub with Sonnet, the shipped MCP cost 6.51x as much.
I tested four ways for agents to reach the same two APIs, GitHub and Takt: curated CLI verbs, one GraphQL query through the CLI, the MCP server as shipped, and one graphql tool behind MCP. That last one was my idea. I called it "ridiculous enough" to try. The agents got 35 read-only questions with verified answer keys, on two models, Sonnet 5.5 in Claude Code and GPT-6.1 Sol in Codex. That's 280 runs, plus 56 targeted repeats where setups disagreed. The details are in the appendix.
Two parts of my story didn't survive even this round. MCP wasn't slower: every MCP setup ran at 0.71-1.06x the CLI's wall time. And the answers barely changed. Most setups solved every question or missed one. The two exceptions, curated gh verbs under Sonnet at 17 of 23 and Takt's shipped MCP at 9 of 12, come back below. The keys checked the answer, not the agent's report, because agents declare victory too early. The difference was the bill. I was ready to call it for CLIs.
The Control That Changed My Mind
There was one problem with my conclusion: I had changed two things at once. The protocol changed, but so did the operations available to the agent. GitHub's MCP server offers curated tools. The GraphQL CLI offers a query language.
So I set up a separate experiment where the operations can't change. Browserious, my remote-browser product, ships a CLI and an MCP server over the same operations, held together by a parity test. I gave both 27 browser QA tasks on a demo account, Sonnet only, one run each.
The penalty mostly vanished.
The control
MCP cost 6.51× the CLI with different operations, and 1.13× with the same ones.
Much of the gap followed the operations, not the protocol.
Left panel: on GitHub with Sonnet, the CLI agent had one composable GraphQL operation and the shipped MCP offered a fixed set of narrower tools. The shipped MCP cost a median 6.51× as much as the GraphQL CLI and made 6.20× the tool calls, over 23 questions. Right panel: in the Browserious control, the CLI and the MCP exposed the same browser operations. Over 27 paired tasks with Sonnet, MCP cost 1.13× as much as the CLI, made 1.09× the tool calls and took 0.85× the wall time. Each bar is drawn against the CLI of its own experiment.
These are separate experiments involving different services and workloads. The Browserious control isolates transport more cleanly but does not directly subtract transport overhead from the GitHub benchmark.
Medians of per-task ratios, MCP ÷ CLI. Benchmark: 23 GitHub questions, shipped GitHub MCP against the GraphQL CLI, Sonnet, one run each. Control: 27 paired browser tasks, Sonnet only, one run each, identical operations on both transports; success was effectively tied. Tool names are examples, not the full sets.
Data behind this figure
| Experiment | Cost | Tool calls | Wall time | Pairs |
|---|---|---|---|---|
| Benchmark: GitHub, Sonnet, shipped MCP / GraphQL CLI | 6.51× | 6.20× | 1.06× | 23 |
| Control: Browserious, Sonnet, MCP / CLI, identical operations | 1.13× | 1.09× | 0.85× | 27 |
With the operations held fixed, MCP cost 1.13x the CLI, close enough that the transport stopped looking like the culprit. It even finished faster. Success didn't separate them either: every run that fell short, in both setups, was a partial on the same pointer-motion check.
These are different services and different tasks, so I can't subtract one ratio from the other, and one control with one model doesn't prove transports are equal everywhere. My reading is that interface design, not transport alone, explains much of the gap. My theory was half right. The cost was real. I'd blamed the wrong thing for it.
The transport did change one thing: secrets. In the MCP setup, the sign-in tasks could only pass the demo password literally, as an argument to
page_fill, since there's no shell or environment expansion. The tool also echoed the value in its result. So the password went through the model's context and into the transcripts. CLI agents typed"$PW", and the value never appeared in the recorded transcript. That's the behavior of the setup I tested, not a law of MCP. If your MCP tools take secrets, resolve them by reference on the server, or authenticate before the agent starts.
Where the Cost Actually Comes From
The biggest differences weren't in the number of tools advertised to the model. They appeared after the tools started returning data.
Small tool lists are a design choice, not a protocol requirement. In a survey of 23 official CLI and MCP pairs, the gcloud MCP server had one tool and still reached every API family the survey sampled. GitHub's had 91 tools and reached 15 of 18. Claude Code also loads only tool names at the start now and fetches a schema when the agent needs it, so a long tool list isn't the bill it used to be.
The bill is what comes back.
Where the cost comes from
The tool definition wasn't the big bill. The returned data was.
Schematic: on a chatty surface each call returns whole objects and the agent calls again; on a composable surface one query returns only the fields it asked for. Measured on GitHub with Sonnet, medians per run, shipped MCP against the GraphQL CLI: raw tool output 172 KB against 8 KB; fresh input tokens 110,041 against 14,138; cache-write input tokens 109,084 against 13,193. Bytes and tokens are separate measures, each on its own scale.
Medians per run, GitHub with Sonnet. Tool-output bytes are what the tools returned in the recorded transcripts: a proxy, not what the model saw, and not every byte was carried forward. Input tokens are summed over the run, not the peak context window. The data supports the mechanism but doesn't isolate every cause.
With Sol, the shipped MCP's raw output was far larger (a median 789 KB per run, one run 18 MB) while its tokens grew little. The likely reason, an inference rather than a measurement, is that the runtime truncated or spilled large results.
Data behind this figure
| Measure (median per run) | Shipped MCP | GraphQL CLI |
|---|---|---|
| Raw tool output per run, KB | 172 | 8 |
| Fresh input tokens | 110,041 | 14,138 |
| Cache-write input tokens | 109,084 | 13,193 |
When I saw the token breakdown, it was exactly the point I'd wanted to find. The shipped MCP's results dwarfed the GraphQL CLI's, and the expensive kinds of input, fresh input and cache writes, grew with them, about 8x for Sonnet on GitHub. Every call returns a full payload, and the conversation carries it into every request after it. The numbers support that mechanism. They don't isolate every cause, and a byte in a transcript isn't proof the byte reached the model.
Sol shows why that caveat matters. Its shipped-MCP runs on GitHub returned a median 789 KB, and one run returned 18 MB, yet used only 1.33x the tokens. My reading is that Codex truncated or spilled most of it before the model saw it. That's an inference, and if it's right, the runtime is quietly deciding what your agent gets to read.
Composability wins on the other side: the GraphQL CLI had the lowest cached input in all four service-and-model cells. One query that names the fields an answer needs beats a dozen calls that return everything.
I care beyond dollars. Context is capped: Sonnet 5.5 gets 1M tokens in Claude Code, and Codex compacts long conversations automatically, which blurs the first instructions. My view is that the noise and the compaction both make the model's intelligence degrade faster. That's my reasoning, not this experiment's finding. No run came near a cap or compacted, and these totals are input summed over requests, not the peak size of the window. I made the broader case in using Claude Code without technical debt: what enters the window shapes everything after it.
Composability Is Not Enough
The fourth setup, the one I called ridiculous, gave MCP a composable surface too: one graphql tool. It cut the cost in three of four cells, and on Takt with Sol it cost 0.96x the GraphQL CLI.
Composability
Giving an agent a composable tool doesn't guarantee composed queries.
Cost relative to the GraphQL CLI, shipped MCP then GraphQL MCP: GitHub with Sonnet 6.51× and 1.82×; GitHub with Sol 1.26× and 1.56×; Takt with Sonnet 1.46× and 1.29×; Takt with Sol 1.12× and 0.96×. Tool calls relative to the GraphQL CLI, shipped MCP then GraphQL MCP: GitHub with Sonnet 6.20× and 1.57×; GitHub with Sol 5.71× and 5.79×; Takt with Sonnet 2.96× and 0.93×; Takt with Sol 3.96× and 1.07×.
Medians of per-question ratios against the GraphQL CLI on the same question. GitHub and Takt have different question counts, the two models ran in different runtimes (Claude Code and Codex), and Sol's costs are list-price API equivalents. One run per cell: these are not model rankings.
Calls and cost are separate measures; a call can be cheap or expensive. The full results table, with tokens and wall time, is in the appendix.
Data behind this figure
| Service, model | Cost, shipped MCP | Cost, GraphQL MCP | Calls, shipped MCP | Calls, GraphQL MCP | Questions |
|---|---|---|---|---|---|
| GitHub, Sonnet | 6.51× | 1.82× | 6.20× | 1.57× | 23 |
| GitHub, Sol | 1.26× | 1.56× | 5.71× | 5.79× | 22 |
| Takt, Sonnet | 1.46× | 1.29× | 2.96× | 0.93× | 12 |
| Takt, Sol | 1.12× | 0.96× | 3.96× | 1.07× | 12 |
The surprise is GitHub with Sol. There the GraphQL MCP cost more than the shipped server, and it still made 5.79x the calls, because Sol fired many small queries instead of composing one.
A composable interface made efficient queries possible. It didn't make the agent compose them.
A thin surface also hides answers. My own MCP server returns Takt comments without dates, and three questions needed them. Sonnet failed all three, twice. Sol got one right once, by inferring dates from comment text and saying they "remain incompletely verified". That's a right answer nobody could check. I haven't acted on it, and I'm not sure I will. Takt's MCP payload is trimmed by design, and it can't answer some questions. C'est la vie. If you trim payloads the same way, write down which questions you're giving up.
Curated CLI verbs have the same edge. With gh verbs only, Sonnet failed all six GitHub questions built to need more than those verbs. Sol solved them with the same verbs, and nobody has read the transcripts for why.
And when the surface falls short, agents route around it. Three of 140 Sol runs broke the access rule, with Python HTTP or a git clone, and were thrown out. In a pilot, a Sol agent whose MCP server failed to start found the real browserious binary and did the task through the CLI. A Sonnet agent in the same spot stopped and reported.
Guessed past or routed around, both look like success until you check how the answer was made.
The Hidden Cost of Two Adapters
The tokens are one bill. Owning two adapters is another. In July, a guard against empty-string arguments went in at Takt's GraphQL boundary and never reached the MCP tools, because the MCP server imports the service layer directly. That bug class has bitten Takt eight times across six layers, once in the CLI itself. The guards went in door by door, then into one shared helper, and the last case into the shared service itself, so every transport rejects the input the same way. That's the rule I'd give anyone with two adapters: validate in the service both of them call, never at either door.
Permissions drift the same way. In one recovery, the MCP tool wouldn't release a stale claim without an admin token, while the CLI released it with the lease id.
Then compatibility. When Takt folded its status tools into one, the three old ones stayed as shims. With MCP, every rename is a shim I carry. GraphQL would also keep the old field, but it lets me mark that field deprecated in the schema, and MCP's Tool schema has no standard equivalent. I'd price that difference in before shipping an MCP server.
Choose the Surface, Then the Transport
The experiments changed the question I ask when building an agent integration. It's no longer "should this be a CLI or MCP?" First I ask what operations the agent needs. Only then do I ask how it should reach them.
The decision
Fix the surface first. The transport only decides who can reach it.
Step 1, the surface: Can the agent answer a real, complex question efficiently with the operations it has? No: improve composition: Field selection, a query operation, task-level aggregates. Then retest cost, correctness and permissions. Then ask again. Yes: go to step 2, the transport: Who needs to reach it? Shell-capable agents: a cli and clear instructions. You own the verb set, the instructions file and provisioning in every agent image. MCP-only clients: an mcp adapter. Remote over HTTPS where needed: you own the endpoint, its authorization and SDK churn. Both: thin adapters over one shared service. Validation and authorization belong in the shared service, with parity tests across adapters.
The order matters more than the answer: the transport question only makes sense once the operations can answer real questions efficiently.
Data behind this figure
| Who needs to reach it | Adapter | Note |
|---|---|---|
| Shell-capable agents | A CLI and clear instructions | You own the verb set, the instructions file and provisioning in every agent image. |
| MCP-only clients | An MCP adapter | Remote over HTTPS where needed: you own the endpoint, its authorization and SDK churn. |
| Both | Thin adapters over one shared service | Validation and authorization belong in the shared service, with parity tests across adapters. |
The surface. Take one real question and see whether the agent can answer it efficiently with the operations it has. If not, fix composition before anything else. You don't need GraphQL for that. Wrapping an API that already composes is cheap, and if yours doesn't, find the call sequence one real task repeats and add an aggregate operation or field selection for it. An MCP server can take a query tool, or code mode, where the agent writes code that combines API calls and returns only the selected result, at the price of an execution sandbox and a permission boundary. Give the new tool the same token and scopes the curated tools already have, and return every field your answers need, dates included.
A one-tool MCP wrapping the API is a nominal MCP, just an adapter between API standards. It adds no capability. It adds reach.
These results surprised me, and I'm now considering a graphql tool for Takt's MCP. I think it's the most significant improvement I could make there, and it would probably let me drop some less popular tools. The other idea, MCP as a mode of the CLI, is still unshipped.
The transport. Agents with a shell get a CLI and an instructions file: when Maestro added a harness with no MCP at all, its agent still did a real Takt task end to end on taktcli alone. Clients that only speak MCP get an MCP adapter: a ChatGPT app, for one, is an MCP server. If you need both, build thin adapters over one shared service, the way Browserious ships both in one package, and test parity every release: an empty string, a null, an omitted field and one call that should be denied, through both doors.
Why I Still Run Both
When taktcli shipped, the plan was "coexist now, deprecate later". Four months on, Takt's MCP server is still mounted. I was ready to sunset it. Then someone asked me specifically for MCP credentials, because their agent environment only accepted pasted MCP settings, not a CLI setup script. A real consumer that only speaks MCP is the reason it stays, and the reason a query tool for it interests me.
"MCP is worse" was the wrong half of my theory. "Build it and you're done" was wrong too. Before you ship either adapter, run one check on your own surface:
- Take one real question with a known answer.
- Run your current surface and a composable alternative with the same model, runtime and credentials, in fresh sessions.
- Record the calls, the bytes returned, and the fresh input, cached input and cost where the runtime reports them.
- Check the answer, and check the agent used only the access you gave it.
Returned bytes are only a proxy for context, and one run is a first diagnostic, not a benchmark. The red flag is repeated fetches that one composed request would replace. If you see them, fix the surface first. Then ship the transport your clients can reach.
Methodology and full results
Setup. The rerun ran on October 19 and 20, 2026, after a first round in September saturated (every setup passed every job). It held each API constant and varied the transport and the surface, a 2x2 per service:
- Curated CLI:
ghortaktcliverbs only, with the GraphQL passthrough refused. - GraphQL CLI:
gh api graphqlortaktcli gql, the baseline for every ratio. - Shipped MCP: GitHub's official server in read-only mode, and Takt's own MCP server.
- GraphQL MCP: one
graphqltool over the same API.
The 35 questions: 12 on Takt boards and wikis, 12 on public repos (cpython, airflow, home-assistant), and 11 on private repos, some mine and some from a team I work with. One run per question, setup and model (280 runs), plus a second run of all four setups on the 14 question-and-model pairs where setups disagreed (56 runs). Every task said to use only the setup's access: no direct HTTP, no clones, no web. Takt runs each got a freshly minted read-only token. GitHub runs used my existing token behind read-only wrappers, the read-only MCP header and a mutation refusal in the GraphQL tool, and a write audit found no writes. Everything cost about $150, pilots included. Sol's dollars are list-price API equivalents, because Codex runs on a subscription.
Results, rep 1. Each setup against the GraphQL CLI on the same question, as the median of paired per-question ratios. A median GraphQL CLI run on GitHub cost Sonnet $0.12 over 7 calls and about two minutes.
| Service, model | Setup | Pairs | Cost | Tokens | Calls | Wall time |
|---|---|---|---|---|---|---|
| GitHub, Sonnet | curated CLI | 23 | 1.10x | 1.14x | 1.17x | 1.02x |
| GitHub, Sonnet | shipped MCP | 23 | 6.51x | 5.06x | 6.20x | 1.06x |
| GitHub, Sonnet | GraphQL MCP | 23 | 1.82x | 1.47x | 1.57x | 0.71x |
| GitHub, Sol | curated CLI | 23 | 1.23x | 1.30x | 1.33x | 1.06x |
| GitHub, Sol | shipped MCP | 22 | 1.26x | 1.33x | 5.71x | 0.99x |
| GitHub, Sol | GraphQL MCP | 22 | 1.56x | 1.59x | 5.79x | 0.99x |
| Takt, Sonnet | curated CLI | 12 | 1.26x | 1.51x | 1.37x | 2.14x |
| Takt, Sonnet | shipped MCP | 12 | 1.46x | 0.97x | 2.96x | 1.06x |
| Takt, Sonnet | GraphQL MCP | 12 | 1.29x | 1.21x | 0.93x | 1.00x |
| Takt, Sol | curated CLI | 11 | 1.27x | 1.31x | 1.29x | 1.72x |
| Takt, Sol | shipped MCP | 12 | 1.12x | 1.27x | 3.96x | 0.96x |
| Takt, Sol | GraphQL MCP | 12 | 0.96x | 1.06x | 1.07x | 0.98x |
Success, rep 1.
| Service, model | Curated CLI | GraphQL CLI | Shipped MCP | GraphQL MCP |
|---|---|---|---|---|
| GitHub, Sonnet | 17/23 | 23/23 | 22/23 | 23/23 |
| GitHub, Sol | 23/23 | 23/23 | 22/22 (+1 invalid) | 22/22 (+1 invalid) |
| Takt, Sonnet | 11/12 | 11/12 | 9/12 | 12/12 |
| Takt, Sol | 11/11 (+1 invalid) | 12/12 | 9/12 | 12/12 |
The repeats mostly reproduced the disagreements, including the curated-verb and missing-date failures.
Browserious control. A separate experiment: 27 paired UI-QA tasks (11 screenshot, 6 recording, 7 form, 3 checklist), Sonnet only, one run each, 54 runs. Median MCP / CLI: cost 1.13x, tokens 1.11x, calls 1.09x, wall time 0.85x. Forms cost 1.50x through MCP, screenshots 1.03x and recordings 1.00x. A median CLI run took 22 calls, $0.26 and 173 seconds, and a median MCP run 23 calls, $0.32 and 152 seconds. The grader saw frames at about 2 per second, too sparse to judge short pointer moves, which is why the partials don't separate the setups.
Vendor survey. 23 comparable official CLI and MCP pairs. MCP reached fewer sampled API families in 12. Vendors name context openly: GitHub's README says choosing toolsets can "reduce the context size", Sentry's architecture notes say its server "must stay under 25 tools", and Azure grouped its tools to keep VS Code under a 128-tool limit. Product scope, permissions and maturity explain plenty of the gaps.
| MCP server | Tools | API families reached |
|---|---|---|
gcloud |
1 | all sampled |
| AWS API | 2 | all sampled |
| Cloudflare code mode | 4 (2 at launch) | all sampled |
| GitHub, all toolsets on | 91 | 15 of 18 (gh: 18), no generic API tool |
"Reached" means at least one real operation per sampled family, not every endpoint.
Caveats.
- Ratios are medians of paired per-question ratios over 11-23 questions per cell, and GitHub and Takt have different question counts.
- The models ran in different runtimes, and single runs don't rank models.
- Sol's dollars are list-price estimates, not billed cost.
- The Browserious control used different tasks on a different service. It isolates transport more cleanly, but its ratios can't be subtracted from the main benchmark's.
- Calls aren't cost, and transcript bytes aren't model-visible context. Sol's truncation or spill is an inference.
- No run came near a context cap or compacted. Token totals are input summed over requests, not peak window use, and context-driven quality loss wasn't measured.
