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.
"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:
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:
// ❌ 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 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.
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.
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.
npm run mcp:translate # ends with the rule table and a per-tool verdictPart 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:
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.
destructiveHintis 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
rootsfor 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.