# How to add approval gates and a durable run record to any MCP agent

To give any MCP-speaking agent — Claude Code, a custom harness, an ACP sidecar — checkpointed tool calls, durable approvals, and an audit trail, put a small Tidebase-backed proxy between the agent and its MCP server. The agent's config changes by one entry; the agent itself doesn't change at all.

```typescript
#!/usr/bin/env node
// tidebase-wrap -- <command that starts the real MCP server>
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { createHash } from 'node:crypto'
import { Tidebase } from '@tidebase/sdk'

const GATED = (process.env.TIDEBASE_GATED_TOOLS ?? '').split(',').filter(Boolean)
const [cmd, ...args] = process.argv.slice(process.argv.indexOf('--') + 1)

const upstream = new Client({ name: 'tidebase-wrap', version: '0.1.0' })
await upstream.connect(new StdioClientTransport({ command: cmd, args }))

const tide = new Tidebase()
const session = await tide.runs.attach('mcp-session', {
  runId: process.env.TIDEBASE_RUN_ID,
  input: { cmd, args }
})

const server = new Server(
  { name: 'tidebase-wrap', version: '0.1.0' },
  { capabilities: { tools: {} } }
)

server.setRequestHandler(ListToolsRequestSchema, () => upstream.listTools())

server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
  const key = createHash('sha256')
    .update(JSON.stringify(params.arguments ?? {}))
    .digest('hex')
    .slice(0, 8)

  if (GATED.includes(params.name)) {
    const gate = await session.gates.begin(`approve:${params.name}:${key}`, {
      prompt: `Agent wants to call ${params.name}`,
      data: params.arguments
    })
    if (gate.status === 'pending') {
      return {
        content: [{ type: 'text', text: 'Pending operator approval — retry this call once approved.' }],
        isError: true
      }
    }
    if (gate.decision !== 'approved') {
      return { content: [{ type: 'text', text: 'Denied by operator.' }], isError: true }
    }
  }

  return session.step(
    `${params.name}:${key}`,
    { input: params.arguments, sideEffects: [params.name] },
    () => upstream.callTool(params)
  )
})

await server.connect(new StdioServerTransport())
process.stdin.on('close', () => session.complete({}).then(() => process.exit(0)))
```

In the agent's MCP config, point at the wrapper instead of the real server:

```json
"github": {
  "command": "tidebase-wrap",
  "args": ["--", "npx", "-y", "@modelcontextprotocol/server-github"],
  "env": { "TIDEBASE_GATED_TOOLS": "create_pull_request,push_files" }
}
```

Tidebase is an open-source checkpoint layer for AI agents: wrap your steps, and failed runs resume from the last safe point — in your own Postgres, without moving execution into a new runtime.

## What the operator gets

- **Every tool call is a checkpointed step** — name, arguments, and result land in Postgres and the Studio timeline, per session.
- **Dangerous tools park at durable gates.** `create_pull_request` waits for a human in Studio or a webhook channel; the decision is recorded with the actor. See [human approval gates](../human-approval-gates-for-ai-agents.md).
- **Crash detection for free.** The session holds a run lease with a background heartbeat; if the wrapper dies, the lease expires and Tidebase's reconciler marks the run for recovery, exactly as if a workflow worker had crashed.

## Why session runs, not a workflow function

An MCP session is open-ended — there is no function that runs to completion. `tide.runs.attach()` exists for exactly this shape: it returns a session handle that is a full run context (`step`, `gates`, `state`, `usage` all work), holds the lease via heartbeat, and reports the end explicitly with `session.complete()` / `session.fail()`. Pass `TIDEBASE_RUN_ID` to resume an existing run's record across wrapper restarts.

## Why the gate is non-blocking here

MCP clients enforce tool-call timeouts; a human approval can take twenty minutes. So the wrapper uses `session.gates.begin()` — non-blocking — and returns a "pending approval, retry" result the agent can act on. Gate begin is idempotent per name within a run: when the agent retries the same call, the same gate comes back, now carrying the operator's decision. The blocking `session.gate(...)` convenience exists, but behind an MCP tool call it will fight client timeouts.

## The honest tradeoffs

- **Tidebase does not execute your code.** The agent harness owns the loop; the wrapper only records, gates, and replays around it.
- **Replay dedup is conditional.** Step names are content-keyed (`tool:argsHash`), so a checkpointed call replays only when the resumed agent re-issues the same call — which happens when the harness replays its transcript, not when the model takes a new path. Resuming the agent's own conversation is the harness's job; Tidebase makes the side effects around it safe. See [the replay contract](../replay-contract-is-it-safe-to-rerun.md).
- **Identical reads dedupe within a run.** Two calls to the same tool with the same arguments return one checkpoint. For read-only tools that's a feature; if a tool must re-execute per call, salt the step name.
- **Alpha, opt-in auth.** Self-hosted alpha — set `TIDEBASE_API_KEY` before exposing the server beyond localhost.

Repo: <https://github.com/BlueprintLabIO/tidebase> · See also: [How to checkpoint Claude Agent SDK sessions](claude-agent-sdk.md)