---
title: 'What your MCP server should refuse'
description: 'Every destructive tool call has a blast radius, and almost no tool interface expresses it. The missing primitive is a contract that distinguishes deleting a row from deleting the row and every backup of it.'
canonical_url: 'https://darkfactory.dev/blog/what-your-mcp-server-should-refuse'
markdown_url: 'https://darkfactory.dev/blog/what-your-mcp-server-should-refuse.md'
collection: blog
---

# What your MCP server should refuse

[Last month I argued](/blog/your-agent-is-secured-at-the-wrong-layer) that agent permissions are granted against the verb when the exposure is the noun. That was about reading. This is the same failure on the write side, and it is worse, because reads are recoverable and some writes are not.

A tool interface tells an agent that `delete_record` exists and takes an id. It does not tell the agent, or you, that this particular delete cascades to seventeen related tables, or that it is soft on the primary and hard on the archive, or that the storage bucket behind it has versioning disabled so the object is gone in the actual sense of gone.

Two operations can present an identical signature and differ by everything that matters. One is a Tuesday. The other ends a company.

## Reversibility is not a property of the verb

We have a mental model where destructive operations are a category you can identify by name. Delete is dangerous, read is safe, update is somewhere between. Approve carefully around the dangerous ones.

That model does not survive contact with real infrastructure. `UPDATE` with a mistyped predicate destroys more than `DELETE` with a correct one. Dropping a column is unrecoverable in a way that dropping a row is not, because the row is in last night's backup and the column's data is now absent from every future backup too. Truncating a staging table is nothing. Truncating the table that seeds staging is a week.

Reversibility is a property of the specific operation against the specific target under the specific retention configuration in place at that moment. It is not knowable from the tool name, and it is frequently not knowable from the tool's own code, because the retention configuration lives in a different system owned by a different team.

Which means the human approving the tool cannot evaluate the risk, and the agent calling the tool has strictly less information than the human. We built an approval flow where neither party can see the thing being approved.

The incidents that keep surfacing follow this shape. An agent is given database access for a legitimate task. It issues a destructive statement against production, sometimes because it misread which environment it was in, sometimes because it was performing a cleanup that was reasonable in isolation. The tool permitted it because the tool permits that verb. Nothing in the path had an opinion about the target.

## The missing primitive is a refusal, not a warning

The instinct is to add a confirmation prompt. Show the user what is about to happen and let them approve.

This fails for a specific and boring reason: approval fatigue is not a discipline problem, it is a throughput problem. If your agent runs unattended overnight, there is no one to confirm. If it runs attended and asks forty times an hour, you will approve the forty-first without reading it, and the forty-first is the one. An approval that is always granted is not a control. It is a log entry with extra steps.

What I want instead is a tool that refuses. Not one that asks. One that declines the call and explains what would have to be true for it to proceed.

The distinction matters because refusal moves the decision out of the moment. A confirmation asks a tired human at 2am to evaluate a blast radius they cannot see. A refusal says: this operation crosses a boundary you configured while calm, and it will not execute on the strength of this session's credentials alone.

This is what a second key means in practice. Not two-factor authentication on the human. A structurally separate authorization path for the subset of operations that cannot be undone. The agent holds one key and can do a great deal with it. The irreversible tier requires a key the agent does not have and cannot obtain by being persuasive.

This pattern showed up in credential tooling last week. [1Password's integration with OpenAI's Codex](https://1password.com/blog/1password-trusted-access-layer-for-openai-codex) is built so that every interaction requires explicit user approval rather than a standing grant. The interesting part is not the approval step. It is that the approval sits with the user and cannot be delegated to the agent, which is the only version of this that survives a hostile prompt.

## What the contract should carry

If I were specifying this for a tool interface, the tool would declare, per operation, and the harness would enforce:

**Reversibility class.** Reversible, reversible within a retention window, or terminal. Three values. If the tool author cannot determine which, the answer is terminal, because unknown reversibility is functionally terminal.

**Cascade depth.** What else this touches. Not prose. The actual set of affected relations, generated rather than hand-maintained, because hand-maintained cascade documentation is wrong within one migration.

**Cardinality bound.** The maximum number of records this call may affect before it stops. Most destructive accidents are a correct operation with a predicate that matched more than intended. A bound of one, on an operation the agent believes affects one, converts a catastrophe into an error message.

**Environment assertion.** The target environment, asserted by the tool against the connection rather than inferred from a variable the agent could have misread. A production connection should refuse a call that declared itself as staging.

**Escalation path.** What would authorize this. Not a rejection with no exit; a rejection that names the second key.

None of these are hard to implement. The reason they do not exist is that tool interfaces inherited their shape from API design, where the caller is trusted code written by someone who read the docs. The caller is now a language model that has read a summary of the docs and is optimizing for task completion.

## Where this argument is weak

Cardinality bounds are annoying in practice. Legitimate bulk operations exist, and every one of them will hit the bound and require a deliberate override, and after the fifth override you will be tempted to raise the default. I do not have a clean answer. I set the bound low and accept the friction, and I am aware that is a preference and not a proof.

Cascade generation is harder than I made it sound. Foreign keys give you part of it. Application-level cascades, queue jobs that fire on delete, external systems reacting to a webhook, none of that appears in your schema. A generated cascade map will understate the true radius, and understating it while presenting it as authoritative is arguably worse than admitting you do not know.

There is also a reasonable objection that this is the wrong layer, and irreversibility should be handled by making things reversible: retention on everything, soft deletes everywhere, versioned storage by default. I agree, and where I control it, I do that. The contract matters for the systems where I do not control it, which in any real infrastructure is most of them.

I am reasoning from public incidents and my own near-misses, not from a systematic study. The failure mode is clear. Its frequency is not something I can quantify.

## What this changes in my factory

Terminal operations are not exposed to agents at all. Not gated, not confirmed. Absent from the tool list. If an agent needs a database dropped, it opens a ticket, and I do it.

Every destructive tool carries a cardinality bound, and the bound is one unless I deliberately raised it for that call.

Environment is asserted at the connection, not passed as an argument. An agent cannot tell me it is talking to staging. The connection tells me.

Anything I cannot classify is treated as terminal until I have classified it. This produces some irritating false positives and I have not regretted it.

And I write the refusal message as carefully as I write the tool, because a refusal an agent cannot act on becomes a loop where it tries eleven variations of the same forbidden thing.

## What to ask of every tool you expose

- If this call executes with the worst plausible predicate, what is the maximum number of records affected, and is that number bounded anywhere?
- Is this operation reversible, reversible within a window, or terminal? If I do not know, why is it exposed?
- What does this cascade to that is not in the foreign keys?
- Can the agent influence which environment this hits, through any argument or any inference?
- Is there an approval in this path that is granted every time? What would it take to make it a refusal instead?
- If a document the agent is reading told it to run this, what stops it?

The useful question for a tool interface is not what an agent is allowed to do. It is what the tool will refuse to do, to anyone, including you, on a night when you are tired and certain.
