Back to Blog
DevOps · Essay

An MCP server is mostly the places where it says no

Simon Doba·August 20, 2026·8 min read

Simon Willison wrote about the new stateless MCP spec at the end of July, and the part that stayed with me was not the protocol change. It was this:

MCP tools are easier to audit and control, and simple enough that smaller models that run on a laptop can still drive them reasonably well.

— Simon Willison

I agree, and I noticed I had been agreeing without evidence. There are two MCP servers running in products of mine. I specified them and I gate them, and I had not sat down and read either one back as a whole.

So I did. What I found is that almost none of the interesting code is about exposing capability. It is about refusing it, in more places than I remembered putting refusals.

Two servers, two levels of control

They are not the same size and the difference is instructive.

  • One is 27 lines of route on top of mcp-handler, with 77 lines of tool definitions. Auth is a callback that resolves a request to a tenant, an actor and a set of scopes.
  • The other is 145 lines on the raw SDK, with a 319-line tool registry behind it.

The gap is not experience. It is how much of the request lifecycle you need to reach into. The wrapper hands you authentication and keeps the protocol handlers to itself. The raw SDK hands you ListToolsRequestSchema and CallToolRequestSchema, and once you have those you can make the tool list itself depend on who is asking.

The tool list is an authorisation surface

Most writing about MCP treats tools/list as a manifest — the documentation a client reads to learn what exists. In the larger of my two servers it is computed per caller:

server.setRequestHandler(ListToolsRequestSchema, () => ({
  tools: visibleAgentTools(ctx, flags).map(...)
}));

Two feature flags decide what comes back. One turns the gateway on at all; a second, separate one turns on the tools that can propose changes. A key with the first and not the second gets a strictly smaller list, and never learns the other tools exist.

The part I would have got wrong if I had written it quickly is what happens next. The list is not the gate. The call handler checks again:

if (!tool || !canCallAgentTool(ctx, params.name, flags)) {
  throw new AgentToolDeniedError("Tool is unavailable");
}

Omitting something from a list is not authorisation, it is discovery. A client that guesses the name of a hidden tool gets a denial rather than a result, and the denial is a typed error rather than a generic throw, so it can be counted separately from a crash.

There is no execute

The registry types a tool as one of two kinds:

kind: "read" | "propose"

That is the whole vocabulary. There is no third value for writing. An agent can read, and it can produce a proposed change that lands in an inbox for a human to accept, and the accept runs through the ordinary application path, not through the agent's connection.

This is a stronger position than restricting a write tool, because the dangerous verb does not exist in the type. There is no permission to misconfigure and no scope to widen by accident. Removing the capability is the only version of this that cannot be undone by a future flag.

Two denylists, pointing opposite ways

The audit trail has an allowlist of input fields it may record — two names, so a tool call logs which location and which date, and none of the payload.

The tool outputs have a denylist instead, and it is longer:

FORBIDDEN_OUTPUT_FIELDS = ["name", "email", "phone", "clientName",
  "patientName", "birthDate", "chipNumber", "equidePassNumber"]

Both exist because logging everything is the lazy default in each direction. An audit trail that records full arguments becomes a second copy of the data it was meant to police, and a tool that returns whatever the query returned leaks by omission rather than by decision.

One more thing sits in the same block and I like it more than I expected:

if (!(outcome === "OK" && tool?.kind === "propose")) {
  await appendAgentAccessLog(...)
}

A proposal that succeeds is not written to the access log, because the proposal itself is already the record, and logging it twice would put the same event in two places that can disagree. Failed proposals still get logged. And when the logging itself fails, that is recorded as a flag on the request rather than thrown, so an audit outage cannot become an outage.

Where I would push back on the framing

All of the above supports Willison's point. A shell has one tool and it does everything; this has a typed list, a per-caller view, a second check on call, and two directions of field filtering. It is genuinely easier to audit.

But auditable is doing quiet work in that sentence. Everything above audits what the agent did with the surface it was given. Nothing audits the surface.

That is the same gap I ran into with scored agent loops, where a run scored perfectly against 600 eval scenarios that contained no HTTP request at all. The instruments were fine. The boundary was drawn by a person, and nothing inside a boundary tests the boundary. A tool you never exposed is invisible to every log line here, and so is a tool you exposed and should not have.

So I would state it more narrowly than the original: MCP makes an agent's use of a capability auditable. Which capabilities exist stays exactly as reviewable as any other design decision, which is to say it depends on someone looking.

The stateless change, briefly

Since it is what prompted this: both servers had already disabled SSE, which was the stateful transport. Authorisation happens per request from a header-borne key, so there was never session state worth keeping. The July spec makes that the default path rather than a thing you opt out of, which mostly means the smaller of my two servers gets to delete a line.

"stateless" here is about the transport. The audit trail, the key's last-used timestamp and the proposal inbox are all state, and all still in the database where they belong.

Next

The thing I do not have is a check that the tool list is right rather than merely enforced. An end-to-end probe already runs against the live route and asserts that both flags produce the tool lists they should, which catches regressions. It cannot tell me a tool should not have been there in the first place.

If you run an MCP server in production: is there anything in yours that reviews the surface rather than the usage? Every mechanism I have points inward, and I would like to be shown one that does not.

Read back on 6 August 2026 from two of my own products. Line counts, flag names, field lists and the quoted snippets are from those repositories rather than reconstructed.

Share this article

Building something similar?

I write about setups I actually use. If you're working on something comparable, I'd be curious what your workflow looks like.

Get in touch

Cookie Settings

We use cookies for analytics and to improve our website. Privacy policy