Dangerous-ops handshake
A short list of MCP tools never execute on the first call. The handler returns a confirm_token + a human-readable summary. The agent surfaces both to the user. The user confirms. The agent re-calls with the token. Only then does the action execute.
Why
Two reasons. First: agents acting on behalf of humans sometimes misinterpret intent. "Upgrade me to Pro" spoken to an agent shouldn't result in a charge without the user seeing the price. Second: even well-intentioned agents have bugs. The handshake makes money-moving + permanent state changes always require an explicit second touch.
Which tools are gated
Today, only the billing-mutation tools:
upgrade_plandowngrade_plan
When we add new tools that move money or permanently widen access, they'll go through this same gate.
The two-call flow
Call 1 — describe
{
"tool": "upgrade_plan",
"input": { "to": "scale" }
}Response:
{
"confirm_required": true,
"confirm_token": "pbc_01J...",
"summary": "Upgrade vector-build from Pro ($19/mo) to Scale ($49/mo). Pro-rated to today; first full Scale charge on May 30.",
"ttl_seconds": 60
}Surface to user
The agent shows the summary in chat verbatim. User reads it, replies "yes" / "confirm" / similar.
Call 2 — execute
{
"tool": "upgrade_plan",
"input": {
"to": "scale",
"confirm_token": "pbc_01J..."
}
}Response:
{
"ok": true,
"subscription": { "plan": "scale", "currentPeriodEnd": "..." }
}Confirm-token rules
- Single use. Once consumed, the token is invalid.
- 60-second TTL. Expires fast so a stale token can't reactivate later.
- Bound to {org, principal, operation, params}. A token issued for upgrade-to-Scale by your agent for your org won't work for a different combination.
- Idempotent on params change. If you re-call with different input, you get a new token + a new summary. The old token is invalidated.
Errors
consent_token_invalid— token doesn't exist or already usedconsent_token_expired— older than 60 secondsconsent_token_mismatch— token was issued for a different operation or different params
Agent implementation pattern
async function upgradePlan(plan: "pro" | "scale") {
// Call 1 — get the confirm token + summary
const r1 = await mcp.callTool("upgrade_plan", { to: plan });
if (!r1.confirm_required) {
// Already a no-op or trivially fast-pathed; done.
return r1;
}
// Surface summary to the human, wait for explicit yes
const userOk = await ask(`${r1.summary}\n\nProceed?`);
if (!userOk) return { cancelled: true };
// Call 2 — execute
return mcp.callTool("upgrade_plan", {
to: plan,
confirm_token: r1.confirm_token,
});
}REST equivalent
The same pattern applies to the REST endpoints behind these tools (POST /api/billing/upgrade, POST /api/billing/downgrade). Same handshake, same token shape.
Related
- Tool catalog — full list of MCP tools.
- Billing portal — the human-side equivalent.