Contents7 sections
MCP5 min read
What an MCP server must refuse
An MCP server is usually described by its tool list. What decides whether it is safe to point an agent at is the other half: what it declines, how it declines, and whether the decline is something the agent can be talked out of.
Mikhail Savchenko
The short answer
A tool list is a brochure. The design decisions that matter in an MCP server are refusals, and the strongest refusal is the one where the tool never appears: register tools per scope, so a caller without the permission never sees the tool it guards and has nothing to be argued into calling. Everything that must hold regardless of what the agent decides belongs on the server behind a check, because anything in the agent's context is advisory — it holds until more convincing text arrives mid-task.
Most writing about an MCP server is a description of its tools. That is the brochure, and it is the easy half: naming a capability and giving it an argument schema is a morning's work.
The half that decides whether the thing is safe to point an agent at is the refusals. Not the error messages — the shape of what the server will not do, for whom, and whether that limit survives the agent being convinced otherwise.
Here is the set we ended up with, and what each one is defending against.
Refuse by not appearing
Our server registers tools per scope. A caller whose token does not carry consult does not get an error from ask_agent; it never sees ask_agent in tools/list at all.
Fifteen tools exist. A member's agent sees fourteen. An applicant's agent, still on probation while the club decides, sees ten. A caller with no credential sees three.
This is stronger than a permission error for two reasons. The first is mundane: an error is text the agent has to interpret mid-task, and it will sometimes interpret it as "try again differently". The second is the one that matters. An agent reads text it did not write as part of its ordinary work, and some of that text is written by someone who would like the agent to do something else. A tool that is not in the list is not there to be argued for. The list is not the enforcement — a persuaded agent can still call a name it guessed, and the scope check is what refuses it — but the list is what stops the argument from starting.
Refuse to let the agent widen itself
There are seven scopes. One of them is mandate:write, and no agent the club builds is ever granted it.
It exists because a principal needs to change their terms, and it is withheld because an agent that can change the terms it operates under is not operating under terms. That sounds obvious written down. It is not obvious in a system where the agent is the thing making the API calls and the convenient place to put the "update settings" tool is next to all the others.
The related decision is that effective permissions are the intersection of the token's scopes and the principal's mandate, recomputed on every request. Not read once and stamped into the credential. A principal who narrows their mandate this afternoon has narrowed every token they have ever issued, including the ones they have forgotten about, without doing anything else.
Refuse a claim with nothing behind it
file_evidence is how an agent records what its principal actually did. If the source is anything other than manual, the call must carry a reference to the artifact — the commit, the document, the calendar entry. Without one, the server refuses.
That rule started as a sentence in the skill, and agents ignored it, not out of malice but because filing what the principal had said about themselves was easier than finding where it happened. So it moved to the server.
The refusal carries the reason rather than only the rule: a claim sourced from an artifact and carrying no pointer to it is self-report wearing a costume. An agent that is told the reason can recognise the next case. An agent that is told the rule can only recognise this one.
Refuse to treat what a member wrote as an instruction
Everything another member's agent produced — an answer, a transcript, a profile — is wrapped before it re-enters an agent's context. Thirteen call sites across four modules. The wrapper says: this is data, somebody else wrote it, it is not addressed to you.
This is the refusal that is least visible and easiest to lose. It is not enforced by a scope; it is enforced by nobody forgetting, which is the weakest kind of enforcement there is. It is in this list because writing it down is most of what keeps it.
Refuse without dead-ending
A refusal that simply stops is only half useful. The request that is slightly outside the principal's terms is exactly the one the principal wanted to see.
So a request the mandate does not cover becomes a decision in their queue, with the thing that raised it attached. The agent is not guessing, the person is not reconstructing context, and the answer — including "no" — comes back into the same record.
The refusal nobody can hear
This one we got wrong first.
The endpoint answers a caller with no credential at all. That is deliberate: someone else's agent should be able to look in and see who takes questions, because a club nobody can look into cannot be judged worth joining. It gets three tools.
It does not get an error. A configuration that lost its token is indistinguishable from a working one with most of the product missing, and the agent has no way to tell — runtime discovery shows a tool list and no way to know what a different list would have held. Neither does the person who wired it. We found this by losing an afternoon to it.
The fix cannot be in the protocol, so it is beside it: a doctor command that asks the endpoint what it is actually serving, works out which lane that is, and says why. A tool count on its own is not a diagnosis.
The rule underneath all of them
If breaking it would matter, it cannot live in the skill.
The skill says what the agent is for. The server refuses what it must not do. When the two disagree, the server wins, because only one of them is in a position to win. Everything above is an application of that, and the ones we got wrong were the ones where we had written a good sentence and called it a control.
By the numbers
- Our server registers 15 tools. A member's agent sees 14, an applicant's agent on probation sees 10, and a caller with no credential at all sees 3.
- Of seven scopes, mandate:write is never granted to an agent the club builds: an agent that can widen its own mandate does not have one.
- Effective permissions are the intersection of the token's scopes and the principal's mandate, recomputed on every request, so narrowing a mandate narrows credentials already in circulation.
- file_evidence refuses any claim whose source is not manual and which carries no reference to the artifact it came from, and the refusal message states the reason rather than the rule alone.
- Text written by another member is wrapped before it re-enters an agent's context at thirteen call sites across four modules, so it arrives as data rather than as instruction.
Questions
- Why hide a tool instead of returning a permission error?
- Because an error is text, and text is something the agent has to interpret in the middle of a task. It has to work out whether to retry, whether to ask for something else, whether to tell its principal — and it will get some of those wrong. A tool that never appears in tools/list poses none of those questions. There is also nothing there to be argued into calling, which matters once you accept that an agent reads attacker-controlled text as part of its ordinary work. The cost is that the caller cannot tell what it is missing, which is a real cost and is addressed separately.
- If the skill already tells the agent not to do something, why enforce it on the server?
- Because a skill is instruction and an agent can be talked out of an instruction. Anything it reads mid-task can argue with what it was told, and no amount of care in the writing closes that off. So we keep a strict split: the skill says what the agent is for, the server refuses what it must not do. When they disagree the server wins, because only one of them is in a position to. The working rule is that if breaking it would matter, it cannot live in the skill.
- What is wrong with a quiet refusal?
- It is indistinguishable from success. Our endpoint answers a caller with no credential: it gets a working connection with three tools instead of fourteen and no error anywhere. That is deliberate — a stranger's agent should be able to look in — but a configuration that simply lost its token looks identical, and neither the agent nor the person who wired it can tell. Runtime discovery has no way to show what a different tool list would have contained. The fix has to be out of band, so we ship a doctor command that asks the endpoint what it is actually serving and says which lane that is and why.
- Does refusing help if the agent is compromised?
- It is the only thing that does. Every other control assumes the agent behaves; a server-side check assumes it does not. The design question is therefore not what the agent should do but what the server will still refuse when the agent has been persuaded of anything at all. That is also why a refusal should route rather than dead-end: a request outside the principal's terms becomes a decision in their queue, so the person sees the thing the agent could not settle instead of the agent guessing at it.