Agents

MCP, or a shell script? The honest comparison

Decided on concrete cases rather than preference. Where a protocol server earns its keep, where a script wins, and the failure that shows up unattended.

MobileByteSensei 8 min read
A capability reachable by a plain call, and one reachable only through a session that is already alive
agentsmcptool-use

I have made this decision both ways in the same project, a week apart, and the reasoning that survived is narrower than either camp's.

It comes down to whether the capability is a function of its inputs or a handle on live state.

The case this post is about

Four capabilities, scored on one question, with the answer printed:

deploy-site          script  the built output on disk is reconstructible
read-live-browser    server  the operator's logged-in session cannot be reconstructed
query-registry       script  a committed YAML file is reconstructible
publish-to-api       script  a local ledger + the API response is reconstructible

One of those four earns a server, and it is also the most expensive — 31 tool schemas described to the model every session whether or not any of them is called.

The fourth row is the one that reads most like a server and is not. It talks to a remote API over the network, which feels like exactly what a protocol server is for, and everything it needs to reconstruct its state is on disk.

This post follows those four rows, because the question that sorts them is not does it touch the network.

The symptom

The question arrives as a preference — protocol servers are the modern way, or scripts are simpler and you should not add a daemon — and preferences do not decide cases.

What makes it hard is that both options genuinely work for most capabilities. You can wrap an HTTP API in a protocol server or in forty lines of shell, and the agent ends up able to call it either way. The difference shows up later, in three specific places: what happens when it breaks, what it costs when idle, and whether it exists at all in an unattended run.

Why the obvious fix failed

The obvious heuristic is complexity: simple things get scripts, complex things get servers.

It fails because complexity is not the axis. The most complex integration I have is create-only against a remote API with no read-back and no delete — genuinely fiddly, and a script is clearly correct for it. The simplest thing I reach for a server for is click this button in the browser that is already open, which is trivial and unscriptable.

The second heuristic is reuse: build a server if more than one project needs it. Also wrong, and in a more expensive way — it optimises for sharing a thing whose cost is mostly incurred per-agent, in context, at every invocation.

The actual mechanism

Ask whether the capability can be reconstructed from arguments.

A shell script is a pure-ish function: it starts, does the thing from what you passed it, exits. If everything it needs can be handed to it — a URL, a token, a path — then a script is the whole answer, and it brings properties a server does not.

A protocol server earns its place when the capability is a handle on something already alive and not reconstructible: a browser with the operator's real cookies and extensions, an editor with unsaved buffers, a debugger mid-session. The value is precisely the state you cannot pass as an argument.

The question that decides it, and which side each case falls on

Two concrete cases from this project, decided opposite ways.

Publishing to a remote API — script. The API turned out to be create-only: create returns 201, and read, update and delete return 405 or 404. There is no session, no state, nothing alive. Every call is fully described by its arguments.

A script wraps it in a few dozen lines that live in the repository, get reviewed, and show up in a diff.

The whole surface, measured against the live API

Driving a browser the operator is logged into — server. The default here is a scripted browser, and it handles nearly everything. The exception is when the issue depends on the real session — actual cookies, actual extensions, actual logged-in state — which a fresh scripted browser cannot reconstruct, and re-authenticating to get it is either impossible or destroys the thing you were trying to observe.

The fix

Three properties that tipped specific decisions, beyond the main question.

A script is inspectable and versioned. It sits in the repository, gets reviewed, and its behaviour on any given day is recoverable from history. A server's behaviour is a running process you did not review, at a version you may not know.

A server's schemas cost context whether or not you call them. Every tool a server exposes has to be described to the model so it knows the tool exists. That description is loaded every session, for every tool, including the twenty you will never call in this project — and unlike a script, which costs nothing until invoked, the cost is paid for existing.

A script fails in one place. When a script breaks you get a non-zero exit and a message. When a server breaks you get a protocol-level failure that could be the server, the transport, the authentication, or the thing behind it — and the agent's view is one layer further from the cause.

The main question is answerable in code, which is worth doing because the two sides of it feel similar and cost very differently:

type Capability = {
  name: string;
  needsLiveState: boolean;      // state the script CANNOT reconstruct from the repo
  stateSource: string;
  toolCount: number;            // schemas described every session if a server
  invocationsPerSession: number;
};

function decide(c: Capability) {
  const SCHEMA_BYTES = 420;
  const serverSessionCost = c.toolCount * SCHEMA_BYTES;   // paid for EXISTING
  const scriptSessionCost = 0;                            // paid only when invoked

  if (!c.needsLiveState) {
    return {
      choice: "script",
      why: `${c.stateSource} is reconstructible; a server would cost ${serverSessionCost} B/session for existing`,
    };
  }
  return {
    choice: "server",
    why: `${c.stateSource} cannot be reconstructed; ${serverSessionCost} B/session buys ${c.invocationsPerSession} live call(s)`,
  };
}

The four capabilities this question came up for:

[
  { name: "deploy-site",      needsLiveState: false, stateSource: "the built output on disk",
    toolCount: 6,  invocationsPerSession: 2 },
  { name: "read-live-browser", needsLiveState: true,  stateSource: "the operator's logged-in session",
    toolCount: 31, invocationsPerSession: 40 },
  { name: "query-registry",   needsLiveState: false, stateSource: "a committed YAML file",
    toolCount: 4,  invocationsPerSession: 90 },
  { name: "publish-to-api",   needsLiveState: false, stateSource: "a local ledger + the API response",
    toolCount: 9,  invocationsPerSession: 1 },
].forEach((c) => { const d = decide(c); console.log(`${c.name.padEnd(20)} ${d.choice.padEnd(7)} ${d.why}`); });
deploy-site          script  the built output on disk is reconstructible; a server would cost 2520 B/session for existing
read-live-browser    server  the operator's logged-in session cannot be reconstructed; 13020 B/session buys 40 live call(s)
query-registry       script  a committed YAML file is reconstructible; a server would cost 1680 B/session for existing
publish-to-api       script  a local ledger + the API response is reconstructible; a server would cost 3780 B/session for existing

The second row is the only one that earns a server and it is also the most expensive — 13 KB of schemas every session, for a capability that genuinely cannot be scripted because the state is a browser somebody is signed into. The fourth is the one that reads most like a server and is not: 3,780 bytes loaded every session to support one invocation, and everything it needs is on disk.

How we proved it

The decisive evidence came from the failure I did not plan for.

An interactively-authenticated dependency — a vault whose session had expired — was needed mid-run. The auth gate tried to prompt, found no interactive terminal, and refused:

⚠ TTY missing — auto-promoting to a dialog
BW_SESSION empty after dialog — aborting

The same path completing in one setting and never starting in the other

That is the generalisable point, and it is stated plainly in the tooling documentation: servers authenticated interactively may simply be absent in headless or scheduled runs. Anything that works because you once approved a dialog is a dependency that does not exist in CI.

For a capability you intend to run unattended, that is close to decisive. A script with its credentials passed as arguments runs identically in both environments. A server holding a session established by a human is, by construction, a thing that sometimes is not there — and the failure arrives at the least convenient moment, because unattended runs are where nobody is watching.

The honest limitation: my incident was not a protocol server, it was a credential vault, and I am generalising from an adjacent case. The mechanism is the same — an interactively-established session that a non-interactive run cannot obtain — but I have not had a protocol server fail this way in production, and I would rather say so than imply the war story is closer than it is.

Where the comparison is genuinely close

For balance, the case I find hardest: a capability that is mostly a function of its inputs but has an expensive setup.

A browser is the clearest example. Launching one costs seconds and some memory; a long session of many small interactions pays that cost once with a persistent server and once per invocation with a script. That is a real argument for the server on efficiency grounds alone, independent of state.

What tips me back is that the scripted version is restartable. If a step fails, the next run starts from a known-empty browser. The persistent session accumulates state — cookies, storage, a page you navigated three steps ago — and reproducing a failure means reproducing that accumulation, which is exactly the debugging problem the script does not have.

So I take the slower, restartable option by default and reach for the persistent one when the accumulated state is the point. That is the same question as before, arriving from the other direction.

What this generalizes to

Ask whether everything the capability needs can be handed to it as arguments. If it can, write the script — it is inspectable, versioned, costs nothing until called, and fails in one identifiable place.

Reach for a protocol server when the value is a handle on live state that cannot be reconstructed, and accept what comes with it: context cost for every tool it exposes whether or not you call them, and a dependency that may not exist when nobody is there to authenticate it.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS