The Two Ways MCP Servers Fail and a Guardrail Layer to Stop Them
MCP servers fail through over-broad tokens and task-blind tools. Scoped tokens, audience checks, sanitized errors and tool caps make a guardrail layer.
This article is published in English.
Sources: MCP Servers Fail in Two Ways, and Both Are Preventable. Here’s the Guardrail Layer.
Most MCP servers don't fail by crashing. They fail by granting more trust than the task ever called for, in two places: the permissions they hand out and the interface they expose. A smarter model won't repair that. A guardrail layer sitting between the agent and the tool will, and the sections below show what such a layer contains, with code.
A cleanup nobody requested
A user tells an agent to copy a production database into a pre-production environment. Working through the request, the agent decides a cleanup step would be tidy and deletes some old test data first. No one asked for that. If the MCP server forwards the user's own credentials, and that user has admin rights, the deletion goes through and production is gone.
AWS cites exactly this scenario in its MCP governance guidance when explaining why scoping tokens can't be skipped.
The server is a trust boundary
People usually describe an MCP server the way they describe any integration glue: something that links an agent to a system, with a handful of tools exposed and the job done. In truth it's the one spot where the model's decision to act becomes a genuine side effect, using real infrastructure and real credentials. All the steps before it are only reasoning. The server is the point where reasoning has consequences.
So the server is a trust boundary, designed that way or not. Look at production servers through that lens and their mistakes sort themselves into two groups.
Failure class one: too much permission
The database story is the general case. AWS's governance guidance names the fix: every downstream call should carry a token scoped on purpose and generated for that call, and never the credentials that started the agent. It adds that the token's audience claim should be validated, which stops a token minted for one MCP server from being replayed on another.
Few teams do this early, since it takes more setup than forwarding the caller's token. Yet it's the whole difference between a hallucinated tool call that fails safely and one that actually hits production.
Failure class two: a bad interface
The second group is subtler. No dramatic incident occurs. Reliability just wears away, and tracing it back is hard. Microsoft's Azure MCP development guidance identifies the underlying habit: servers get designed like REST APIs, with each operation exposed as a separate tool, when the design should follow what the agent wants to achieve. Its illustration makes the point well. Instead of four tools called create_ticket, add_comment, assign_ticket and notify_user, a single file_support_request tool does all four steps internally.
The guidance points to two further symptoms of that habit. Descriptions may promise things the tool doesn't deliver, so the agent trusts text the code never honors. And error messages may be useless ("Error") or too revealing: stack traces, internal file paths, or a confirmation that a particular user exists in your database, which on its own enables enumeration attacks.
An asymmetry in where suspicion goes
Consider how much engineering goes into an agent's final reply: structured-output validation, guardrails, retries and verifier steps. Model output is treated as suspect, and rightly so.
Now consider the tool-calling layer under that reply. The caller's token goes straight through. The tool list expands with nobody checking whether the model can still reason over it. A tool's description is accepted as written, and no test ever shows an agent using the tool correctly. Huge effort goes into doubting what the model says, and next to none into doubting what it does.
This lopsided suspicion is the real defect behind both groups. Neither smarter permission logic nor better tool names alone will cure it. What's needed is a layer that checks every tool call, and not just every model reply, before extending trust to it.
Building the layer
The unit here is the tool call. Don't let a raw request reach the backend. Wrap it first.
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
@dataclass
class ScopedToken:
audience: str # which MCP server this token is valid for
permissions: list[str] # e.g. ["read", "create"] - never assume "all"
issued_at: datetime
expires_at: datetime
source_user: str # who originally triggered this, for audit
Each field in that wrapper exists so the next layer can judge how much to trust the call. Now come the controls.
Scope the token before the tool runs
Don't reuse the credentials the caller arrived with for a downstream action. Issue a narrower token that covers just what the tool is about to do.
def issue_scoped_token(user_token: ScopedToken, tool_name: str,
required_permissions: list[str]) -> ScopedToken:
# Never grant more than the tool declares it needs
granted = [p for p in required_permissions if p in user_token.permissions]
if set(required_permissions) - set(granted):
raise PermissionError(
f"{tool_name} requires {required_permissions}, "
f"caller only has {user_token.permissions}"
)
return ScopedToken(
audience=tool_name,
permissions=granted,
issued_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(minutes=5),
source_user=user_token.source_user,
)
This is the piece that makes the database scenario fail safely. A request scoped to read and create simply can't perform a delete, whatever the model decided.
Check the audience on every call
A token minted for one tool should be refused by every other tool.
def enforce_audience(token: ScopedToken, expected_tool: str) -> None:
if token.audience != expected_tool:
raise PermissionError(
f"Token issued for '{token.audience}' cannot be used on '{expected_tool}'"
)
Shape the tool around the task
This applies the file_support_request pattern from Azure's guidance:
def file_support_request(customer_email: str, issue_type: str, description: str) -> dict:
ticket = create_ticket(issue_type, description)
add_comment(ticket.id, f"Filed by {customer_email}")
assign_ticket(ticket.id, team=route_by_type(issue_type))
notify_user(customer_email, ticket.id)
return {
"ticket_id": ticket.id,
"status": "open",
"assigned_team": ticket.team,
}
A single call carries a single clear intent, so the agent never has to get the sequencing of four operations right on its own.
Sanitize errors before the agent sees them
Record the full detail on the server and give the agent only what it needs to recover.
def safe_error(internal_message: str, request_id: str) -> dict:
# internal_message goes to your logs, never to the model
log.error(internal_message, extra={"request_id": request_id})
return {
"content": [{
"type": "text",
"text": f"Unable to complete the request. Request ID: {request_id}. "
f"Try again or contact support."
}],
"isError": True,
}
Cap the tool count per server
Learn the limit before production does. Microsoft's guidance gives a number: past 10 to 15 tools in one server, split by domain and don't keep growing a single server.
MAX_TOOLS_PER_SERVER = 15
def register_tool(server, tool):
if len(server.tools) >= MAX_TOOLS_PER_SERVER:
raise ValueError(
f"{server.name} already has {len(server.tools)} tools. "
f"Split into a domain-specific server instead of adding more."
)
server.tools.append(tool)
Three quick checks on an existing server
None of these needs a rebuild.
Audit token scope, not only token presence. Pull ten recent tool calls and compare the permissions each token carried with what the tool needed. If the two usually differ, broader tokens than necessary are being passed around, and that's likely the current state.
Do the token arithmetic for your own tool count. AWS's guidance estimates 5,000 to 10,000 tokens per invocation spent on descriptions alone, once a server goes beyond 20 tools. Count the tools you have. A server far past that, with no filtering or splitting, suffers from context bloat that no model change will fix.
Try an agent-level test that likely hasn't been done. Take the three busiest tools and see, using a real agent and not a unit test, whether it selects the right one from an everyday request in natural language. Microsoft's guidance describes this as the layer almost everybody omits. Passing unit and protocol tests without ever doing this leaves it unknown whether the tools work.
How it fits with the rest
None of this displaces existing reliability work, and it sits alongside it. Scoped tokens follow the same instinct as validating input at any trust boundary. A cap on tool count and a task-centered design echo the discipline of API versioning and interface design, only for a surface that a model reads and not a human developer. Each MCP server is yet another place where the system decides how far to trust a caller, so it warrants the same care as the rest.
Teams adopt MCP because it gives agents more reach: one standard route into real systems instead of a hundred custom integrations. The catch is that a model carries out a poorly scoped call just as faithfully as a well-scoped one, because it has no sense of the difference. Only the guardrail layer does.
Permission failures and interface failures seem unrelated, one concerning access and the other design. Both grow out of one unguarded assumption, that a tool call has earned trust nobody actually granted it. Most AI systems aim their suspicion at the output side. The tool-call side merits it as well.
That leaves one thing to ask of each MCP server in use: what currently prevents an agent from acting on a call that carries excess trust, and would an alert fire if it already had?
Quick answers
What is MCP token scoping? It means giving each tool call its own narrow token for that purpose, and not forwarding the caller's original credentials. AWS's governance guidance recommends it so that a hallucinated or unintended action can't succeed simply because the underlying credentials permitted it.
How many tools should one MCP server expose? Azure's guidance offers 10 to 15 as a rule of thumb, after which the server should be split by domain. Further on, agents pick the right tool noticeably less often, and AWS's guidance puts the cost of a long tool list at 5,000 to 10,000 tokens per call for descriptions alone beyond 20 tools.
How does task-oriented tool design differ from system-API design? System-API design makes every backend operation its own tool, mirroring a REST API. Task-oriented design offers what the user is trying to achieve as one tool that handles the steps internally. Microsoft's example is a single file_support_request tool in place of four ticketing operations.
Why sanitize MCP error messages? Stack traces, internal paths or hints that a certain record exists give attackers details they shouldn't get, which is at least an enumeration weakness. A safer approach writes the full detail to server-side logs and returns the agent only what it needs to recover, along with a correlation ID.
Do unit tests catch these problems? They don't. Unit tests cover a tool's logic alone and protocol tests cover MCP compliance, but neither proves an agent can find, choose and correctly use the tool in a realistic situation. That needs a third testing layer driven by an actual agent, which most teams leave out.