---
title: Approval Server
description: HTTP endpoints that gate paused tool calls, how to approve or deny them, and how to auto-start an approver.
---

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).

:::caution
The HTTP server is **not** a web UI. There is no index page at `/` — requesting one returns 404. The server only exposes the endpoints documented below, all behind bearer-token auth.
:::

## When the server runs

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.

```mermaid
sequenceDiagram
  participant C as MCP Client
  participant P as mcp-proxy
  participant AP as Approver
  participant S as MCP Server

  C->>P: tools/call (risk score ≥ 50)
  P->>P: match pause rule
  P-->>AP: stderr: PAUSED approval_id
  AP->>P: POST /api/tool-calls/{approval_id}/approve
  P->>S: forward tool call
  S-->>P: result
  P-->>C: tools/call result
  Note over P: sign receipt and store
```

## Pin a predictable port

`-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:

```json
{
  "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](/mcp-proxy/configuration/#multiple-mcp-clients-running-the-proxy-simultaneously).

## Endpoints

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

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/tool-calls/{approval_id}/approve` | Approve a paused call. Unblocks the proxy to forward the request to the MCP server. |
| `POST` | `/api/tool-calls/{approval_id}/deny` | Deny 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...
```

### Approve a call

```bash
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"}
```

### Deny a call

```bash
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`.

## What the client sees

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:

```json
{
  "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`.

## Opting out of approval

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](/mcp-proxy/configuration/#policy-rules) for the full rule schema.

## Running an approver

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:

### Watch stderr and prompt the terminal

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

```bash
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)
```

### Auto-approve everything (non-production)

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:

```bash
sqlite3 ~/.local/share/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
```

## Auto-start with the MCP client

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.
