SpotAgentIQ

How to require human approval for specific MCP tool calls

Jordan McKoy7 minute read

Let the agent read freely, ask a person before it sends or spends, and refuse card numbers outright. This is the setup, line by line.

Say you have a support agent in Claude Desktop with three MCP servers: your CRM, Gmail and Stripe. It's useful. It's also one cleverly worded ticket away from emailing a customer's order history to a stranger or refunding an order twice.

You don't want to approve every lookup. You do want to see every outgoing email and every refund before it happens. Here's how to get exactly that.

1. Move the servers behind a proxy

Your claude_desktop_config.json probably looks like this today:

{
  "mcpServers": {
    "crm":    { "command": "npx", "args": ["-y", "@acme/crm-mcp"] },
    "gmail":  { "command": "npx", "args": ["-y", "@acme/gmail-mcp"], "env": { "GMAIL_TOKEN": "..." } },
    "stripe": { "command": "npx", "args": ["-y", "@acme/stripe-mcp"], "env": { "STRIPE_SECRET_KEY": "rk_live_..." } }
  }
}

(The package names are placeholders; use whichever servers you actually run.) Each server talks to Claude directly, so there's nowhere to put a check. Move those three entries into a SpotAgentIQ config as upstreams. SpotAgentIQ launches them and re-exposes their tools unchanged:

{
  "agent": "support-bot",
  "principal": "support@yourcompany.com",
  "listen": ":8484",
  "token": "${SPOTAGENTIQ_TOKEN}",
  "log_path": "/var/log/spotagentiq/support-bot.jsonl",
  "approval_timeout": "5m",
  "default_action": "allow",
  "upstreams": [
    { "name": "crm",    "command": "npx", "args": ["-y", "@acme/crm-mcp"] },
    { "name": "gmail",  "command": "npx", "args": ["-y", "@acme/gmail-mcp"], "env": { "GMAIL_TOKEN": "${GMAIL_TOKEN}" } },
    { "name": "stripe", "command": "npx", "args": ["-y", "@acme/stripe-mcp"], "env": { "STRIPE_SECRET_KEY": "${STRIPE_KEY}" } }
  ],
  "rules": []
}

Then replace the three entries in the Claude config with one:

{
  "mcpServers": {
    "spotagentiq": { "command": "spotagentiq", "args": ["serve", "--config", "/etc/spotagentiq/support-bot.json"] }
  }
}

Restart Claude Desktop. The agent sees the same tools. Nothing is held yet, because there are no rules, but every call is already being logged.

2. Refuse the things you'd never approve

Start with deny rules, because they're the cheapest. No person should have to look at a card number being pasted into a tool, or a live secret key:

{ "match": "*", "action": "deny", "if_args_match": "\\b\\d{13,19}\\b", "reason": "Looks like a card number" },
{ "match": "*", "action": "deny", "if_args_match": "(sk|rk)_live_[0-9a-zA-Z]+", "reason": "Looks like a Stripe secret key" }

"match": "*" means every tool on every server. Because rules are checked top to bottom and the first match wins, these go first.

When one fires, the agent doesn't crash. It gets a tool result saying the call was blocked and why, and it will usually tell the user something like "I can't process card details directly; please use the payment link."

3. Hold anything that leaves the building

{ "match": "gmail/send_*",   "action": "require_approval", "reason": "Email to a customer" },
{ "match": "gmail/draft_*",  "action": "allow" },

The gmail/ prefix scopes the rule to that one server, so a send_* tool on some other server isn't caught by accident. Drafts are allowed: the agent can write as many as it likes, and a person approves the send.

4. Hold money, above a threshold

Holding every refund will train your team to click approve without reading. Stripe amounts are in the smallest currency unit, so five or more digits means $100.00 or more:

{ "match": "stripe/create_refund", "action": "require_approval", "if_args_match": "\"amount\":\\s*[0-9]{5,}", "reason": "Refund of $100 or more" },
{ "match": "stripe/create_refund", "action": "allow" },
{ "match": "stripe/*",             "action": "require_approval", "reason": "Stripe write" },

The last line catches every other Stripe tool, including ones added in a future version of the server, so they don't slip through on the default.

Wait: wouldn't stripe/* also hold stripe/list_customers? Yes. Put an allow rule for reads above it:

{ "match": "stripe/list_*",     "action": "allow" },
{ "match": "stripe/retrieve_*", "action": "allow" },

5. Hold deletes, and keep secrets out of the log

{ "match": "*/delete_*", "action": "require_approval", "reason": "Destructive" }
"redact": ["password", "token", "api_key", "authorization", "card_number"]

Redacted keys are masked at any depth in the log, the inbox and Slack. The upstream still gets the real value.

The whole thing

"rules": [
  { "match": "*", "action": "deny", "if_args_match": "\\b\\d{13,19}\\b", "reason": "Looks like a card number" },
  { "match": "*", "action": "deny", "if_args_match": "(sk|rk)_live_[0-9a-zA-Z]+", "reason": "Looks like a Stripe secret key" },
  { "match": "gmail/send_*",         "action": "require_approval", "reason": "Email to a customer" },
  { "match": "gmail/draft_*",        "action": "allow" },
  { "match": "stripe/create_refund", "action": "require_approval", "if_args_match": "\"amount\":\\s*[0-9]{5,}", "reason": "Refund of $100 or more" },
  { "match": "stripe/create_refund", "action": "allow" },
  { "match": "stripe/list_*",        "action": "allow" },
  { "match": "stripe/retrieve_*",    "action": "allow" },
  { "match": "stripe/*",             "action": "require_approval", "reason": "Stripe write" },
  { "match": "*/delete_*",           "action": "require_approval", "reason": "Destructive" }
],
"redact": ["password", "token", "api_key", "authorization", "card_number"]

What approving looks like

Open http://localhost:8484, enter the token and your name. When the agent tries to send an email, it appears within a couple of seconds with the full recipient, subject and body, plus the rule that caught it. Approve and the email goes out and Claude gets the real result. Reject with a note ("don't promise a refund date") and Claude sees the note and adjusts.

Add "slack_webhook" to get each held call in a channel with a link to it. If nobody responds within approval_timeout, the call is rejected, never approved.

A week later

Run spotagentiq export and look at what you approved. If every email to @yourcompany.com was approved unchanged, add an allow rule for it above the hold. If refunds under $100 never caused trouble, leave them. The point is a short queue of things worth reading.

The full rule syntax is in the policy docs.