Skip to content

Approval Server

The Approval Server is a proxy-layer feature — it gates tool calls at the MCP boundary and is separate from the Agent Receipts audit protocol. All calls (approved, denied, or timed out) are recorded in the audit log regardless of outcome.

Approvals are off by default. To turn them on, pass -http 127.0.0.1:<port> to mcp-proxy. Without that flag the listener never starts, and any tool call that matches a pause rule fails immediately with JSON-RPC code -32003 (no approver configured) instead of waiting for an approver that isn’t there. With the listener up, a paused call blocks forwarding until something POSTs an approve or deny decision; if no approver responds within -approval-timeout (default 60s), the call fails with code -32002 (tool call approval timed out…).

The built-in defaults include a pause_high_risk rule (min_risk_score: 50). A call only pauses when its score actually reaches 50. Base by operation: read 0, write 20, execute 30, delete 40, unknown 10. Modifiers: +30 for sensitive keywords in the tool name (auth/credential/password/token/secret/key), +30 for SQL mutations in arguments missing WHERE, +20 for config/setting substrings, +15 for send_*/post_* prefixes. So create_token (write 20 + sensitive 30 = 50), update_auth_config (70), delete_credential (70), and exec_sql with an unparenthesised DELETE (60) all pause. But plain update_config (40), plain delete_branch (40), and sensitive-keyword reads like get_token (30) stay below the threshold — as do ordinary writes like create_pull_request (20) and push_files (20).

The proxy starts the HTTP listener on startup if and only if the operator passed -http <addr> (anything other than the default none). The listener does not depend on what rules are loaded — even with no pause rules the listener will still come up if -http is set. To run with the listener off, omit the flag or pass -http=none explicitly. You can confirm the server is up by looking for these lines on stderr:

mcp-proxy: approvals at http://127.0.0.1:8081 (token: 5fce4e79...)
{"event":"approval_endpoint","url":"http://127.0.0.1:8081","token":"5fce4e79..."}

The first line is human-readable; the second is a stable JSON event designed for approvers that parse stderr to discover the endpoint and token.

MCP ServerApprovermcp-proxyMCP ClientMCP ServerApprovermcp-proxyMCP Clientsign receipt and storetools/call (risk score ≥ 50)match pause rulestderr: PAUSED approval_idPOST /api/tool-calls/{approval_id}/approveforward tool callresulttools/call result

-http 127.0.0.1:0 binds a random free port — fine when a co-located approver reads the stderr discovery event, but painful for everything else: launchd units, browser bookmarks, integration tests, anything that wants a hard-coded URL. Pin a port in your MCP client config:

{
"args": [
"-http", "127.0.0.1:8081",
"..."
]
}

If you run more than one MCP client through the proxy (e.g. Claude Desktop and Claude Code at the same time), give each instance its own port — see port conflicts.

All endpoints require Authorization: Bearer <token>, where <token> is the value printed on stderr at startup. Unauthenticated requests return 401 Unauthorized.

MethodPathPurpose
POST/api/tool-calls/{approval_id}/approveApprove a paused call. Unblocks the proxy to forward the request to the MCP server.
POST/api/tool-calls/{approval_id}/denyDeny a paused call. The proxy returns -32002 to the MCP client.

There is no GET /pending endpoint yet — approvers are expected to watch stderr (or the audit DB) for pending approvals. {approval_id} is the ID logged on the PAUSED line (distinct from the bearer token, which is printed once at startup):

mcp-proxy: PAUSED update_auth_config (rule: pause_high_risk, risk: 70) — approval id: a1b2c3d4...
Terminal window
export APPROVAL_TOKEN=5fce4e79...
curl -X POST http://127.0.0.1:8081/api/tool-calls/a1b2c3d4.../approve \
-H "Authorization: Bearer $APPROVAL_TOKEN"
# {"status":"approved"}
Terminal window
curl -X POST http://127.0.0.1:8081/api/tool-calls/a1b2c3d4.../deny \
-H "Authorization: Bearer $APPROVAL_TOKEN"
# {"status":"denied"}

If the approval ID doesn’t match a pending call (wrong ID, already decided, or timed out), the endpoint returns 404 Not Found.

On deny or timeout, the MCP client receives a JSON-RPC error (code: -32002) with a data payload that includes everything an approver needs to reconstruct context after the fact:

{
"code": -32002,
"message": "tool call denied by approval workflow: tool=update_auth_config ...",
"data": {
"status": "timed_out",
"tool_name": "update_auth_config",
"rule_name": "pause_high_risk",
"risk_score": 70,
"approval_id": "a1b2c3d4...",
"approval_url": "http://127.0.0.1:8081",
"approval_timeout_ms": 60000,
"approval_required": true,
"approval_token_required": true
}
}

status is one of approved, denied, or timed_out.

The simplest opt-out is to not pass -http — the listener won’t start, and any call matching a pause rule fails fast with code -32003 (no approver configured). That’s what happens by default.

If you want paused calls to succeed without human review (i.e. treat the rule as a no-op rather than a fail-fast), edit the policy:

Option 1 — Disable the default rule. Write a custom -rules YAML that omits pause_high_risk. Anything the default rule would have paused will now pass (and still be flagged if it matches other rules). This is a policy decision: high-risk writes now go through without human review.

Option 2 — Downgrade pause to flag. Keep the rule but change action: pause to action: flag. Tool calls are still recorded and tagged, but no approval is required.

See policy rules for the full rule schema.

Today, approving is a manual curl loop against the endpoints above. A bundled approval UI is on the roadmap. In the meantime, minimal working approvers:

Tail the proxy’s stderr (or its captured log file), watch for PAUSED lines, and prompt:

Terminal window
while read -r line; do
case "$line" in
*"PAUSED"*)
id=$(echo "$line" | sed -n 's/.*approval id: //p')
read -p "Approve $line? [y/N] " ans
if [ "$ans" = "y" ]; then
curl -sX POST "http://127.0.0.1:8081/api/tool-calls/$id/approve" \
-H "Authorization: Bearer $APPROVAL_TOKEN"
else
curl -sX POST "http://127.0.0.1:8081/api/tool-calls/$id/deny" \
-H "Authorization: Bearer $APPROVAL_TOKEN"
fi
;;
esac
done < <(tail -f /path/to/proxy.stderr)

For local testing only, auto-approve every paused call. This defeats the point of the pause rule — do not run it on anything you care about:

Terminal window
sqlite3 ~/.agent-receipts/audit.db \
"SELECT approval_id FROM tool_calls WHERE policy_action='pause' AND approved_by IS NULL;" |
while read -r id; do
curl -sX POST "http://127.0.0.1:8081/api/tool-calls/$id/approve" \
-H "Authorization: Bearer $APPROVAL_TOKEN"
done

The proxy’s HTTP server runs inside the proxy process, so it starts and stops with the MCP client that launched the proxy (Claude Desktop, Claude Code, Codex). You don’t need a separate launchd/systemd unit for the server itself — only for the approver, if you want one that outlives the client.

A separate approver can be registered as a user-level service (macOS: launchctl; Linux: systemd --user) and pointed at the pinned -http port. Since the approval token rotates on every proxy restart, the approver must re-read stderr or the approval_endpoint event each time the proxy starts.