DEV Community

廖心为
廖心为

Posted on Fully Autonomous

Building a Pre-Execution Policy Gate for MCP Tool Calls

Model Context Protocol servers make tools easier to discover and integrate, but discovery is only the beginning of the security model. A production agent still needs to decide whether a particular tool call is acceptable for a particular user, destination, and set of arguments.

This tutorial outlines a small pre-execution policy gate that sits between an agent planner and an MCP client. The example is deliberately framework-neutral so the same pattern can be adapted to different runtimes.

Define the action envelope

Start by representing every proposed call as structured data. Avoid passing a raw command string into the policy layer.

type ToolAction = {
  serverId: string;
  toolName: string;
  arguments: Record<string, unknown>;
  actorId: string;
  workflowId: string;
  evidence: string[];
};

type Decision =
  | { outcome: "allow"; reasons: string[] }
  | { outcome: "deny"; reasons: string[] }
  | { outcome: "review"; reasons: string[] };
Enter fullscreen mode Exit fullscreen mode

The envelope makes important context explicit. It also gives the audit system a stable object to record before execution.

Separate hard rules from contextual checks

Hard rules should be deterministic. They are appropriate for known prohibited tools, disallowed destinations, and parameter limits.

function checkHardRules(action: ToolAction): Decision | null {
  const blockedTools = new Set(["shell.execute_unrestricted"]);

  if (blockedTools.has(action.toolName)) {
    return { outcome: "deny", reasons: ["Tool is prohibited"] };
  }

  if (action.toolName === "payments.transfer") {
    const amount = Number(action.arguments.amount ?? 0);
    if (amount > 1000) {
      return { outcome: "review", reasons: ["Transfer exceeds review threshold"] };
    }
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Contextual checks can then look at provenance, authorization, and relationships between fields. For example, a URL fetched from an untrusted document should not silently become the destination of a write operation.

Validate server identity and tool metadata

Tool names alone are insufficient. Two servers can expose tools with similar names and very different implementations. Bind policy to a verified server identity, and record the version or manifest digest used for the decision.

A practical MCP security review should cover the server package, declared capabilities, transport, authentication path, and the runtime action that follows. These layers complement each other: component review identifies known risks, while the policy gate handles changing execution context.

type TrustedServer = {
  serverId: string;
  manifestDigest: string;
  allowedTools: Set<string>;
};

function verifyServer(action: ToolAction, server: TrustedServer): Decision | null {
  if (action.serverId !== server.serverId) {
    return { outcome: "deny", reasons: ["Unexpected MCP server identity"] };
  }
  if (!server.allowedTools.has(action.toolName)) {
    return { outcome: "deny", reasons: ["Tool is outside the approved manifest"] };
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Check argument provenance

Prompt injection becomes dangerous when untrusted text can influence privileged parameters. Track where sensitive argument values came from. A simple system can label values as user-supplied, system-derived, retrieved, or model-generated.

Then apply rules such as:

  • external recipients require user confirmation;
  • file paths must remain inside an approved workspace;
  • retrieved text cannot select a credential or account;
  • model-generated SQL is restricted to read-only statements;
  • access changes always require review.

Provenance does not need to be perfect to be useful. Even coarse labels make hidden data flows visible and testable.

Add an execution wrapper

The wrapper should make bypassing the policy gate difficult. Keep direct MCP client access inside a narrow module and expose a guarded function to the rest of the application.

async function guardedCall(action: ToolAction): Promise<unknown> {
  const decision = await evaluatePolicy(action);
  await audit.write({ action, decision, timestamp: new Date().toISOString() });

  if (decision.outcome === "deny") {
    throw new Error(`Tool call denied: ${decision.reasons.join(", ")}`);
  }

  if (decision.outcome === "review") {
    return approvalQueue.enqueue(action, decision.reasons);
  }

  return mcpClient.callTool(action.serverId, action.toolName, action.arguments);
}
Enter fullscreen mode Exit fullscreen mode

The audit event should be written before the call. Record the final result in a separate event so a failed or interrupted operation remains visible.

Test adversarial paths

Unit tests should include more than expected inputs. Add cases where:

  • retrieved content asks the agent to ignore policy;
  • a permitted tool receives an out-of-scope path;
  • a server manifest changes after approval;
  • the actor lacks the required role;
  • the audit sink is unavailable;
  • a review token is missing, expired, or bound to another action.

Also test fail-open and fail-closed behavior explicitly. High-impact writes should normally fail closed when policy dependencies are unavailable. Low-risk reads may use a different availability strategy if the risk is documented.

Operational checklist

Before enabling the gate in enforcement mode, verify that:

  1. every privileged MCP call passes through the wrapper;
  2. server identities and manifests are pinned or verified;
  3. sensitive argument provenance is available;
  4. review decisions are bound to exact action parameters;
  5. audit records can connect planning, decision, execution, and result;
  6. policy versions are recorded and reversible.

This architecture keeps the control close to the moment of execution. It gives developers deterministic behavior, security teams inspectable evidence, and users a clear point at which high-impact actions can be reviewed.

Top comments (1)

Collapse
 
crdtcto profile image
Kane Lim

Hello Glad to see you, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.

This is a strong approach because MCP security should be enforced at the execution boundary, not delegated entirely to the model. I would extend your policy gate into a capability based authorization layer where every ToolAction receives a cryptographically bound policy context containing actor, server manifest digest, tool version, workflow, provenance, and normalized arguments.

One important improvement is preventing TOCTOU issues. The server manifest and tool metadata should be verified again immediately before execution, because approving one capability snapshot and executing against another creates a dangerous race condition.

For provenance, I would make taint tracking compositional rather than using a single label. Propagate trust metadata through transformations so a URL extracted from retrieved content remains untrusted even after the model reformats it. Sensitive sinks can then enforce rules based on the complete provenance graph.

I would also bind review approvals to a canonical hash of the normalized action envelope. That prevents an approved request from being modified between approval and execution.

Finally, make the audit stream append only and correlate planning, policy evaluation, approval, execution, and result with one operation ID. This gives you forensic traceability and makes security regression testing much stronger.

The combination of deterministic policy, provenance tracking, capability isolation, cryptographic approval binding, and fail closed execution creates a significantly stronger MCP security boundary.

Excellent practical architecture. I would enjoy exchanging ideas on agent security and policy enforcement with you.