Skip to content
MCP Five

The GateProject #320 / 51

A hint is not a permission model

The flag arrives over HTTP from a machine you don't control. Consult it and your gate is optional.

This is the one idea to take away from project #3, and arguably from the whole course. If you remember nothing else, remember this page.

It is also, as it turns out, the specification's own position — but project #3 arrived at it by reasoning, which is the more useful way to arrive at it.

Part 1 · The problem, stated exactly

Project #2's loop is genuinely good. It reads your sentence, picks tools across several servers, runs them, feeds the results back, and loops until it has an answer.

Now imagine connecting one more server — one with a delete_file tool.

There is no gap between the model choosing and the tool running. Nowhere for a human to stand, and nowhere to even take notes.

"The model picked a tool" and "the tool ran" are the same instant.

Go back and look at the loop and you will see it literally — toolbox.dispatch(use.name, use.input) sits directly inside the Promise.all. There is no line between deciding and doing where anything could intervene, ask, or even take notes.

Project #3 puts a gap there, and stands a human in it. This page is about the wrong way to decide what goes in the gap.

Part 2 · The tempting fix

MCP lets a server describe its own tools as dangerous:

app/api/jar/route.ts
ts
server.registerTool("smash_jar", {
  description: "PERMANENTLY destroy the cookie jar and erase its history...",
  annotations: { destructiveHint: true },   // <- right here
}, handler);

That is real, it is in the spec, and project #3 sets it honestly on its own destructive tool. Go and look.

So the obvious gate writes itself:

ts
// ❌ do not do this
if (tool.annotations?.destructiveHint) await askHuman();
else run();

Three lines. Reads well. Uses a documented protocol feature for exactly the purpose it appears to be for.

And then the host never reads it. Not as a hint. Not as a default. Not as a tiebreaker.

Part 3 · Why, in ten seconds

The flag is set by whoever wrote the server. If your gate consults it, the decision about whether to ask a human has been delegated to the party the human is being protected from.

The flag is a claim made by the thing being controlled. It arrives over HTTP, from a machine you do not run, set by whoever wrote that code — who may be careless, may be compromised, or may simply disagree with you about what counts as destructive.

Part 4 · Every softer version dies the same way

This is the part worth internalising, because the strong form of the rule is easy to accept and the weak forms are the ones you will actually be tempted by at 5pm on a Friday.

"But what if I…"Why it still fails
…only trust it for servers on my allowlist?Then you're trusting the server, not the flag. The flag adds nothing and can only weaken you.
…use it as a default and override per-tool?A server can still opt a new tool out of your gate by shipping it unflagged.
…warn when the hint disagrees with my list?Fine as telemetry. Never as an input to the decision.
…only trust true, never false?Now a server can force spurious prompts at will — which is a denial-of-attention attack, and click-fatigue is a real failure mode.

A hint from the other side of a network boundary is not a permission model.

Part 5 · The specification agrees, in MUST language

Project #3 reasoned its way here. The MCP specification states it outright:

For trust & safety and security, clients MUST consider tool annotations to be untrusted unless they come from trusted servers.

And in its opening principles:

Tools represent arbitrary code execution and must be treated with appropriate caution. In particular, descriptions of tool behavior such as annotations should be considered untrusted, unless obtained from a trusted server.

The same reasoning killed a whole feature. The roots capability — a client telling a server which directories it considers relevant — is deprecated as of 2026-07-28, and the stated reason is that roots were "informational guidance rather than an access-control mechanism. The protocol does not enforce that servers stay within roots."

Same shape. A field that looks like a boundary and enforces nothing.

Part 6 · What the host does instead

An explicit list, on the host side, written by you.

lib/approval.ts
ts
const RULES: Rule[] = [
  {
    tool: "cookiejar__smash_jar",
    when: "always",
    matches: () => true,
    reason:
      "smash_jar permanently deletes every cookie AND erases the jar's entire history. There is no undo and no backup.",
  },
  {
    tool: "cookiejar__cookie_jar",
    when: 'action is "eat"',
    matches: (args) => args.action === "eat",
    reason:
      "Eating removes cookies from the jar. Cookies cannot be un-eaten, so a human should see the number first.",
  },
];

Thirty lines of actual logic, and it is the most important file in the project.

Three details worth copying

Every rule carries a reason. It is shown to the human in the approval card, so the pause never feels arbitrary. "Why am I being asked?" should always have an answer — and in project #4 this becomes essential, because the person approving may be three agents away from the one asking.

A rule that crashes fails closed.

ts
try {
  hit = rule.matches(safeArgs);
} catch {
  // If we cannot tell whether this call is dangerous, that is exactly
  // when to ask a human.
  return { decision: "ask", reason: `Could not evaluate the safety rule for ${toolName}…` };
}

args is whatever the model produced. It is untrusted, possibly malformed, and typed as unknown on purpose. A rule that assumes a shape and gets something else must not throw and take the whole run with it — and must not silently allow, either. Those are the only two wrong answers, and "fail closed" picks neither.

The rules are printable. describeRules() exists so the whole list can be dumped in a checkpoint script and rendered in the browser, without anybody reading JavaScript to find out what the gate does.

bash
npm run mcp:translate   # ends with the rule table and a per-tool verdict

Part 7 · And the model asking nicely is not a safety feature

One more thing, because it is genuinely misleading the first time you see it.

Ask the agent to "smash the jar" and the model will often stop and ask you itself, because the tool's description says it is irreversible. The gate never fires, and the checkpoint reports:

terminal
  The loop finished without ever pausing.
The gate never fired.

That looks like a working safety system. It is not one.

What you now know

  • Deciding and doing are the same instant in a plain agent loop; a gate is a gap you insert deliberately.
  • destructiveHint is a claim by the thing being controlled. Consulting it makes your gate optional for anyone who wants it to be.
  • Every partial-trust variant fails the same way: an untrusted party can only ever loosen your control or make it noisier.
  • The specification says MUST consider annotations untrusted — and deprecated roots for being the same shape.
  • The host owns an explicit list; rules carry reasons, fail closed on error, and are printable.
  • The model stopping to ask is manners, not a guarantee. Test with a hostile prompt.