7 October 2026|3 min read
Fail-closed by default: MCP servers that agents can't talk their way past
The model can ask; the server decides. What 'fail-closed' means in practice when AI agents touch business systems.
- MCP
- Security
- Agents
Agents are only useful with real business data. But if every agent ships its own API client, credentials and retry logic, each one is a security review waiting to happen, and nobody can answer a simple question: which agent touched this record, and was it allowed to?
I wanted one controlled place between agents and each business system, so that question becomes a query instead of an investigation. That is what the two MCP servers in my Enterprise MCP Layer do: agents speak MCP, and the server decides what they may do.
The rule: the model can ask, the server decides
An LLM is not a security boundary. It can be prompted, confused or just wrong. So every check lives in the server, in plain code, and runs the same way on every single call.
- Tool call
- Valid API key?
- Agent identity
- Allowed for this resource + operation?
- Within rate limit?
- Validated input
- Narrow, typed operation
- Audit event
What fail-closed actually means
A fail-open system lets a request through when it can't decide: a missing key, a broken config, an unknown tool. A fail-closed system doesn't. In practice: no key, a bad key, a disabled key or an expired key means no access, and there is no anonymous fallback. The same goes for permissions. Anything not explicitly granted is denied, and anything not explicitly built doesn't exist as a tool.
What did the work
- An identity per key. Each API key maps to a named agent or application with an owner, a status, an expiry and a rate limit.
- Allow-lists, not block-lists. Agents reach only the resources and operations listed for them. In one server, the backing database exposed far more tables than agents needed; table-level allow-listing and query guards closed that.
- Narrow, typed tools. Structured parameters, never 'let the model build an HTTP request'.
- Reads first, writes explicit. Writes are a separate permission, and deletes are off by default.
- Audit everything. Every call records the agent, tool, resource, decision and result. Secrets are never logged.
{
"agent": "support-agent",
"resource": "case_tracker",
"permissions": {
"read": true,
"add_rows": true,
"update_rows": true,
"delete_rows": false
}
}A lesson from the code I started from
The reference implementation I began with had an authentication bypass: a path where 'no credential' quietly meant 'allowed'. That is exactly the failure fail-closed is designed to prevent. I replaced it with a key system where every request has to positively prove itself.
How I test it
The tests that matter are the boring negative ones: missing, invalid, disabled and expired keys are rejected; unauthorised resources and operations are denied; rate limits trigger; audit records are written; and keys and audit history survive a container restart.
Honest limits
Both servers run on an internal network only. TLS, SSO and a secret manager would come before any wider rollout, and human approval for high-risk writes is designed but not built yet.