The Model Context Protocol (MCP) is quietly connecting AI agents to everything and handing them real power. I’ve felt this firsthand when connecting my agents to identity-admin MCP servers and watching them provision OAuth clients, or to email MCP servers to organize my inbox and send emails on my behalf. While this is genuinely useful, it represents delegated authority without direct oversight. The most dangerous thing about my AI agent is not that it’s smart; it’s that it’s authorized on my behalf, and I’m responsible for whatever it does.
To prevent my AI agent from performing disastrous actions, there needs to be a guardrail in place, driven by runtime policy. This article shows a pattern for building that guardrail around an MCP server without modifying it.
The pattern: a policy enforcement point in front of the MCP server
The idea is the classic authorization split, applied to MCP:
- A Policy Enforcement Point (PEP) intercepts every request; any HTTP reverse proxy will do. The agent connects to the proxy’s URL, and the proxy forwards requests to the real MCP server downstream.
- A Policy Decision Point (PDP) decides what’s allowed, per request, given who the caller is and which tool is being invoked. It also decides whether human-in-the-loop (HITL) is necessary.
In this example, our PEP will be a Cloudflare Worker, our PDP will be PingOne Authorize, and the HITL will be a PingID push to the human’s phone, with the pending proof held in Cloudflare KV. The downstream MCP server will be the official PingOne remote MCP server, but any remote MCP server would do.
The guard pattern: a policy enforcement point in front of the MCP server
The Cloudflare Worker reverse proxy will have three jobs:
-
Identifying the caller: The proxy identifies the caller from the OAuth token the agent presents. It assumes the token’s
subclaim is the human user, and that is the whole mechanism: no usablesub, no identity, and risky calls fail. -
Judging every call: The proxy passes the tool name along with the caller’s identity to PingOne Authorize’s decision endpoint, and policy determines the outcome: whether a call is permitted outright, denied, or conditionally allowed subject to step-up approval. For requests that require step-up approval, a state machine between Cloudflare KV and PingID orchestrates the decision end to end.
-
Gating on the human’s decision: The proxy triggers a PingID push to the identified caller, parks a pending challenge in KV with a five-minute time-to-live (TTL), and closes the HTTP request. After the human approves this PingID push and invokes the agent again, the worker finds the challenge in KV, sees it has been approved in PingID, and forwards the request to the target MCP server.
Closing the loop: hashes, TTLs, and overwrites
There is a subtle trap in this design worth highlighting: if an approval is keyed only to the user and the tool name rather than the exact payload, a second tool call could piggyback on the first approval while its TTL is active. Deleting user A and deleting user B should always be two separate decisions.
To prevent this, the proxy enforces three strict operational guardrails:
- Payload hashing: The proxy hashes the tool name together with its input arguments to generate a unique KV key. A verified approval authorizes
deleteUser {"userId": "c6d3..."}— only that exact action, for the 2-minute approval window, and nothing else. - One active challenge per user: Any incoming guarded tool call instantly overwrites an existing parked challenge, ensuring a user only ever has one active request waiting for approval.
- Strict expiration: Challenges expire after 5 minutes in KV, while completed approvals expire after 2 minutes. Because entries vanish automatically, stale approvals can never accumulate into standing permission.
Every KV entry represents a HITL state machine and moves through the same short lifecycle:
- Parked (5-minute TTL): A guarded call arrives, the push goes out, and the proxy parks the open challenge in KV. At this point, the entry authorizes nothing; it is a resumable pointer to the pending PingID push, and its only job is to outlast human latency.
- Approved: When the human approves and the client retries, the proxy checks the push status once. On approval, the challenge entry is deleted immediately and replaced with an action-scoped approval entry.
- Trusted (2-minute TTL): This second entry is the only thing that can forward a call. For 2 minutes, the exact same action re-runs without another prompt.
- Gone: When the TTL lapses, KV removes the entry on its own. Nothing lingers, and no approval ever becomes standing permission.
Lifecycle of a challenge entry in Cloudflare KV
Approvals that mean something
HITL security only works if the human is actually paying attention. If an agent prompts for permission on every minor action, approval fatigue takes over. I’ve lost count of how many times I’ve blindly smashed Enter on a Claude prompt without reading it. Gating must be selective, triggering approvals only when a tool call carries genuine risk.
Another crucial touch is payload context. The PingID MFA challenge embeds the specific tool call parameters into the push notification itself. Rather than a vague “Approve request” prompt, the user gets explicit detail: “Step-up approval for: deleteUser { ... }”. Without visibility into the action being authorized, human approval is just security theater.
Benefits
- This approach lets me write granular PingOne policies to gate actions like
deleteUserbehind human verification, while flat-out blocking destructive calls like deleting my own account or wiping an entire environment. Because every policy decision is evaluated dynamically at execution time, updates take effect immediately. This is runtime identity in practice. - The beauty of this pattern is that the agent never touches the authorization decision: it can’t see, fake, or bypass it. All the agent can do is tell the human to approve the notification and retry the call; the guardrail only lets the request through once PingID confirms approval. Because the Cloudflare Worker operates as a transparent MCP proxy, this entire HITL flow requires zero modifications to the AI agent or the downstream MCP server.
Takeaways
- You don’t need to fork an MCP server to govern it, even a third-party one you merely consume: A thin proxy at the HTTP boundary sees every
tools/calland can enforce policy the server was never built to know about. - Step-up is advice, not a decision: Expressing it as advice attached to a Permit in PingOne Authorize kept the policy declarative; the PDP decides what’s required, and the PEP decides how to collect it.
- Never hold a request open for a human: Park the pending proof in state with a TTL and let the client resume.
- The agent is a messenger, not a decision-maker: It can surface the step-up instruction to the human and retry, but the guardrail forwards only when the human actually approved.
- Approve the action, not the tool name: Show the full payload in the notification, and scope the grant to that same payload. Tool-name-only approvals leak between invocations: action-scoped ones don’t.
Join the discussion on the Ping Identity developer community.
