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.
#!/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:
"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_requestwaits for a human in Studio or a webhook channel; the decision is recorded with the actor. See human approval gates. - 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. - 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_KEYbefore exposing the server beyond localhost.
Repo: https://github.com/BlueprintLabIO/tidebase · See also: How to checkpoint Claude Agent SDK sessions