The round trip

  1. The agent calls request_approval with the action, its arguments, what it touches and whether it can be undone. The server gives the request an id and version 1, then holds the call open.
  2. The host sees _meta.ui.resourceUri on the tool, loads ui://approval-gate/view.html into a sandboxed frame and hands it the tool input.
  3. The view asks the server for the request it holds through approval_request, so the person reviews the arguments that will run. The model's wording of them stays out of the gate.
  4. The person approves, rejects or edits in Approval Gate. The view sends the decision through approval_decide.
  5. The server checks the decision against its copy: the same request, the current version (an edit counts as the next version), a scope it offers, and no earlier decision. It stamps a serverReceiptId and releases the waiting call with the decision. The gate's receipt then reads "Verified".

The three view tools carry visibility: ["app"], so the host hides them from the model. Only the view can record a decision.

The agent's answer

{
  "requestId": "req_6f1c…",
  "requestVersion": 2,
  "outcome": "granted",
  "scope": "once",
  "editedArgs": { "ids": [101], "status": "closed-lost" },
  "decidedAt": "2026-10-04T15:17:51.743Z",
  "serverReceiptId": "rcpt_93b0…"
}

This is the ApprovalDecision type from the Approval Gate core, returned as the tool's structured content. The text content tells the model the rule: run the action only on granted, and with editedArgs when the person edited it.

Fail closed

  • A host without MCP Apps support gets denied at once, since the person cannot see the gate there.
  • An unanswered request expires after five minutes (APPROVAL_TIMEOUT_MS).
  • If the agent cancels its call, the request settles as denied.
  • The server refuses a stale version, an unknown request or a second decision, and the call keeps waiting.
  • While the call waits, the server sends a progress notification at 10-second intervals, so a host that restarts its timeout on progress keeps the call open.

Run it

Node 22.6 or later. From a clone of the repository:

cd examples/mcp-apps-approval
npm ci
npm run build      # bundles the view into dist/view.html
npm test           # the decision round trip, in memory and over stdio

Open it in the MCP Inspector, then pick the Apps tab and "Ask the person to approve an action":

npm run inspector

Or add it to any host that supports MCP Apps over stdio:

{
  "mcpServers": {
    "approval-gate": {
      "command": "node",
      "args": ["--experimental-strip-types", "--no-warnings", "/path/to/examples/mcp-apps-approval/src/main.ts"]
    }
  }
}

Source, tests and screenshots: examples/mcp-apps-approval. The example pins the MCP TypeScript SDK 2.3.0, @modelcontextprotocol/ext-apps 2.0.3 and the MCP Apps spec dated 2026-01-26.

Before you ship it

The example keeps requests in memory in one process, which suits stdio. A remote server needs the requests in shared storage, an authenticated person behind each decision, and an executor that checks the stored grant for the exact version before it runs anything. Revoke only helps if that executor checks again.