Security·Human-in-the-Loop·Access Control

MCP server tool approval workflows: human-in-the-loop confirmation for high-risk tools

Not all MCP tools carry the same risk. Listing issues is read-only and reversible; deleting a repository is neither. For tools with significant consequences — deleting records, sending emails, executing financial transactions, modifying production infrastructure — requiring a human approval before execution is the most reliable safeguard against prompt injection and runaway agent loops. This guide covers how to classify tool risk, implement an async approval gate, deliver approval requests through actionable channels, and maintain a complete audit trail of every approval decision.

Step 1: classify tools into risk tiers

Not every tool needs human approval — that would make the MCP server useless. The goal is to apply approval gates selectively to the subset of tools where an unintended invocation has significant consequences.

// src/lib/tool-registry.ts
export type RiskTier = "safe" | "review" | "critical";

interface ToolDefinition {
  name: string;
  risk: RiskTier;
  description: string;
}

export const TOOL_REGISTRY: ToolDefinition[] = [
  // Safe: read-only, no side effects
  { name: "list_issues",     risk: "safe",     description: "List project issues" },
  { name: "get_file",        risk: "safe",     description: "Read file contents" },
  { name: "search_docs",     risk: "safe",     description: "Search documentation" },

  // Review: write operations with recoverable side effects
  { name: "create_issue",    risk: "review",   description: "Create a new issue" },
  { name: "add_comment",     risk: "review",   description: "Comment on an issue" },
  { name: "update_status",   risk: "review",   description: "Update issue status" },

  // Critical: destructive or irreversible operations
  { name: "delete_issue",    risk: "critical", description: "Permanently delete an issue" },
  { name: "send_email",      risk: "critical", description: "Send email to users" },
  { name: "deploy_service",  risk: "critical", description: "Trigger a production deployment" },
  { name: "drop_table",      risk: "critical", description: "Drop a database table" },
];

Step 2: implement the async approval gate

The approval gate intercepts tool invocations for review and critical tools, stores the pending invocation, delivers an approval request to the human approver, and blocks execution until a decision arrives or a timeout expires.

// src/lib/approval-gate.ts
import { db } from "./db.js";
import { sendApprovalRequest } from "./approval-channels.js";
import { log } from "./logger.js";
import { TOOL_REGISTRY } from "./tool-registry.js";

type ApprovalOutcome = "approved" | "denied" | "timeout";

interface PendingApproval {
  id: string;
  toolName: string;
  callerId: string;
  argsHash: string;
  argsPreview: string;  // human-readable summary, NOT full args
  requestedAt: string;
  expiresAt: string;
  outcome?: ApprovalOutcome;
  decidedBy?: string;
  decidedAt?: string;
}

export async function requireApproval(
  toolName: string,
  callerId: string,
  args: unknown,
  timeoutSeconds = 300,
): Promise<void> {
  const toolDef = TOOL_REGISTRY.find(t => t.name === toolName);
  if (!toolDef || toolDef.risk === "safe") return; // No gate for safe tools

  const pendingId = crypto.randomUUID();
  const expiresAt = new Date(Date.now() + timeoutSeconds * 1000).toISOString();

  // Store with hashed args — never store args in plaintext
  await db.pendingApprovals.create({
    id: pendingId,
    toolName,
    callerId,
    argsHash: hash(args),
    argsPreview: summarizeArgs(toolName, args),
    requestedAt: new Date().toISOString(),
    expiresAt,
  });

  // Deliver approval request (Slack, email, TOTP — see below)
  await sendApprovalRequest({
    approvalId: pendingId,
    toolName,
    callerId,
    argsPreview: summarizeArgs(toolName, args),
    tier: toolDef.risk,
    expiresAt,
  });

  log.info({ tool: toolName, callerId, approvalId: pendingId }, "approval gate: waiting");

  // Poll for decision
  const decision = await pollForDecision(pendingId, expiresAt);

  if (decision !== "approved") {
    log.warn({ tool: toolName, callerId, approvalId: pendingId, outcome: decision }, "approval gate: denied");
    throw new ToolError(toolName, `Tool invocation ${decision} — approval required for ${toolDef.risk} operations`);
  }

  log.info({ tool: toolName, callerId, approvalId: pendingId }, "approval gate: approved");
}

function summarizeArgs(toolName: string, args: unknown): string {
  // Return a human-readable summary that does NOT include credential fields
  // Each tool should have a custom summarizer; this is the default
  const safe = JSON.stringify(args, (key, val) =>
    ["apiKey","token","password","secret"].includes(key) ? "[redacted]" : val
  );
  return safe.length > 200 ? safe.slice(0, 200) + "…" : safe;
}

async function pollForDecision(id: string, expiresAt: string): Promise<ApprovalOutcome> {
  const deadline = new Date(expiresAt).getTime();
  while (Date.now() < deadline) {
    const record = await db.pendingApprovals.findById(id);
    if (record?.outcome) return record.outcome;
    await new Promise(r => setTimeout(r, 2000)); // Poll every 2s
  }
  // Mark as timed out
  await db.pendingApprovals.update(id, { outcome: "timeout" });
  return "timeout";
}

Step 3: deliver approvals via actionable channels

The approval request needs to reach a human who can make a quick decision. Three common channels:

// Slack interactive message with Approve / Deny buttons
async function sendSlackApproval(req: ApprovalRequest): Promise<void> {
  await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      channel: process.env.APPROVAL_SLACK_CHANNEL,
      blocks: [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `*MCP tool approval required*\n*Tool:* \`${req.toolName}\` (${req.tier})\n*Caller:* ${req.callerId}\n*Args preview:* \`${req.argsPreview}\`\n*Expires:* ${req.expiresAt}`,
          },
        },
        {
          type: "actions",
          elements: [
            {
              type: "button",
              text: { type: "plain_text", text: "Approve" },
              style: "primary",
              action_id: "approve_tool",
              value: req.approvalId,
            },
            {
              type: "button",
              text: { type: "plain_text", text: "Deny" },
              style: "danger",
              action_id: "deny_tool",
              value: req.approvalId,
            },
          ],
        },
      ],
    }),
  });
}
// Slack action handler — records the approval decision
app.post("/slack/actions", async (req, res) => {
  const payload = JSON.parse(req.body.payload);
  const action = payload.actions[0];
  const approvalId = action.value;
  const outcome = action.action_id === "approve_tool" ? "approved" : "denied";
  const decidedBy = payload.user.id;

  await db.pendingApprovals.update(approvalId, {
    outcome,
    decidedBy,
    decidedAt: new Date().toISOString(),
  });

  res.json({ text: `Decision recorded: ${outcome} by ${decidedBy}` });
});

Step 4: audit trail requirements

Every approval decision must be written to an immutable audit log with the following fields. This record is required for SOC 2 Type II compliance and post-incident review:

interface ApprovalAuditRecord {
  approvalId: string;          // UUID linking to tool invocation
  toolName: string;
  tier: "review" | "critical";
  callerId: string;            // agent or user requesting the tool
  argsHash: string;            // SHA-256 of args — for correlation, not reproduction
  requestedAt: string;         // ISO 8601
  expiresAt: string;
  outcome: "approved" | "denied" | "timeout";
  decidedBy?: string;          // human approver ID (undefined if timeout)
  decidedAt?: string;
  channel: "slack" | "email" | "totp";  // how the approval was delivered
}

SkillAudit grade impact

Human-in-the-loop approval is evaluated under the Permissions Hygiene axis. Findings:

For related patterns, see the ambient authority problem and function-level authorization.