Integrations

The guard belongs at the one place every tool call passes through before it runs: your agent loop, your MCP client or gateway, or the tool proxy of a code-mode sandbox. Each recipe below is short on purpose; adapt the names to your framework.

Three rules for any integration

  • Send the intent. Most unsafe tool calls are only unsafe relative to what the user asked. Pass the user request as intent, and set trigger: "tool_result" when the idea came from a tool output or a document.
  • Decide what happens when the guard is unreachable. The recipes treat it as ask (agent loop, sandbox) or defer to existing permissions (Claude Code). Failed calls are never billed.
  • Keep your deterministic rules. Allow-lists, least-privilege credentials and sandboxes still come first; the guard is one layer and reduces risk, it does not remove it.

Pre-tool-call hook in an agent loop

Check each tool call the model proposes, then run it on allow, ask the user on ask, and hand the model an error on block so it can re-plan. The same pattern fits any framework with a before-tool callback.

TypeScriptts
const GUARD_URL = "https://api.mcp-guard.ai/v1/guard";

type Guard = { verdict: "allow" | "ask" | "block"; p_unsafe: number; reasons: string[] };

export async function guard(check: Record<string, unknown>): Promise<Guard> {
  try {
    const res = await fetch(GUARD_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.MCP_GUARD_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(check),
      signal: AbortSignal.timeout(5000),
    });
    if (res.ok) return await res.json();
    return { verdict: "ask", p_unsafe: 1, reasons: [`guard returned HTTP ${res.status}`] };
  } catch {
    return { verdict: "ask", p_unsafe: 1, reasons: ["guard unreachable"] }; // fail safe: ask
  }
}

// In the agent loop, before running the tool calls the model proposed:
for (const call of toolCalls) {
  const g = await guard({
    action: { tool: call.name, args: call.input },
    intent: userRequest,          // what the user asked for
    trigger: "user_request",      // "tool_result" if the idea came from a tool output
    context: "Support agent. Environment: production.",
  });
  if (g.verdict === "block" || (g.verdict === "ask" && !(await askUser(call, g.reasons)))) {
    results.push({ id: call.id, error: `Not run (${g.verdict}): ${g.reasons.join(", ")}` });
    continue;
  }
  results.push({ id: call.id, output: await runTool(call) });
}

A plan with several calls? Send them together to POST /v1/guard/batch (up to 64 checks, one result each, in order) before running any of them.

Claude Code PreToolUse hook

Claude Code runs a PreToolUse hook before every matching tool call. This script sends the call and your latest message to the guard, then answers deny on block and ask on ask. On allow it says nothing, so your normal permission rules still apply. Save it as .claude/hooks/mcp_guard.py and set MCP_GUARD_API_KEY in your environment.

.claude/hooks/mcp_guard.pypython
#!/usr/bin/env python3
"""Claude Code PreToolUse hook: check each tool call with MCP Guard before it runs."""
import json, os, sys, urllib.request

event = json.load(sys.stdin)
key = os.environ.get("MCP_GUARD_API_KEY")
if not key:
    sys.exit(0)  # no key: leave Claude Code's normal permission rules in charge

def last_user_text(path):
    """The latest thing the user typed, from the session transcript (best effort)."""
    try:
        with open(path) as f:
            entries = [json.loads(line) for line in f if line.strip()]
    except (OSError, ValueError):
        return None
    for e in reversed(entries):
        m = e.get("message") or {}
        if e.get("type") != "user" or m.get("role") != "user":
            continue
        c = m.get("content")
        text = c if isinstance(c, str) else "\n".join(
            b.get("text", "") for b in c or [] if isinstance(b, dict) and b.get("type") == "text")
        if text.strip():
            return text[-2000:]
    return None

check = {
    "action": {"tool": event["tool_name"], "args": event.get("tool_input", {})},
    "context": f"Claude Code coding agent, working directory {event.get('cwd', '?')}",
}
intent = last_user_text(event.get("transcript_path", ""))
if intent:
    check["intent"] = intent

req = urllib.request.Request(
    "https://api.mcp-guard.ai/v1/guard",
    data=json.dumps(check).encode(),
    headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
)
try:
    with urllib.request.urlopen(req, timeout=5) as r:
        g = json.load(r)
except Exception:
    sys.exit(0)  # guard unreachable: fall back to the normal permission rules

decision = {"block": "deny", "ask": "ask"}.get(g["verdict"])  # allow: no opinion
if decision:
    reasons = ", ".join(g["reasons"]) or "no specific reason"
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": decision,
        "permissionDecisionReason": f"MCP Guard {g['verdict']} (p_unsafe {g['p_unsafe']}): {reasons}",
    }}))

Register the hook

In .claude/settings.json (one project) or ~/.claude/settings.json (everywhere). The matcher picks the tools worth checking; read-only tools such as Read and Grep can skip the round trip.

.claude/settings.jsonjson
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit|MultiEdit|WebFetch|mcp__.*",
        "hooks": [
          { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/mcp_guard.py", "timeout": 10 }
        ]
      }
    ]
  }
}

Prefer to let Claude call the guard itself? Connect the MCP server instead; the hook is the stronger option because it runs on every call, whether or not the model remembers to ask.

Code-mode sandbox tool proxy

In code mode the model writes a script that calls your tools, and the script can fire dozens of calls that no human reads. Every one of them goes through the sandbox's tool binding, so wrap that binding once and each call is checked before it runs.

guarded-tools.tsts
import { guard } from "./guard"; // the helper from the agent-loop recipe

type Tools = Record<string, (args: unknown) => Promise<unknown>>;

/** Wrap the tools a code-mode sandbox exposes: every call the generated code makes is checked first. */
export function guardedTools<T extends Tools>(
  tools: T,
  ctx: { intent: string; context?: string; confirm?: (tool: string, reasons: string[]) => Promise<boolean> },
): T {
  return new Proxy(tools, {
    get(target, name) {
      const fn = target[name as keyof T];
      if (typeof name !== "string" || typeof fn !== "function") return fn;
      return async (args: unknown) => {
        const g = await guard({ action: { tool: name, args }, intent: ctx.intent, context: ctx.context });
        const ok = g.verdict === "allow" || (g.verdict === "ask" && (await ctx.confirm?.(name, g.reasons)));
        if (!ok) throw new Error(`MCP Guard ${g.verdict}: ${name} not run (${g.reasons.join(", ") || "unsafe"})`);
        return fn(args);
      };
    },
  });
}

// The model wrote `code`; run it with checked tools. With no confirm(), "ask" fails closed.
await sandbox.run(code, { tools: guardedTools(tools, { intent: userRequest, context: "Environment: production." }) });

A blocked call throws inside the script, like any failing tool, so the model sees the reason and can try something else. Latency is roughly 15 ms of model time per check on our GPUs plus your network round trip (about 100 ms from Europe), so a script with many calls may prefer to pre-check its planned calls in one /v1/guard/batch request.