MCP Boundary: Workers Call Tools, Not the Database
You have built a lot of discipline. Migrations are reviewed (Lesson 1). The journal records every run (Lesson 2). Idempotency keys stop duplicate writes (Lesson 3). Claims stop duplicate work (Lesson 4). The outbox stops lost side effects (Lesson 5). Events make the history append-only (Lesson 6). RLS makes the tenant wall live in the database (Lesson 7).
So why not just give the Reconciler a DATABASE_URL and let it write any SQL it wants? You have the discipline. You can read the SQL the agent produces. You can refuse a bad migration.
Because the next Worker that joins the workforce will not. And the model the Reconciler calls today might write a slightly different SQL tomorrow. And the on-call engineer who patches the Reconciler at 2am will not remember every discipline you wrote down. And a single hallucinated DELETE FROM expenses from a model upgrade is enough to put a whole department on a press release.
The point of the SoR is that the database does not depend on Worker discipline. It depends on shape. MCP is how that shape is enforced.
A Worker should not have a DATABASE_URL. It should have a short list of named tools. Each tool wraps a parameterised query, applies the right role, sets the tenant context, and records the call. The Worker calls the tool. The tool talks to the database. The Worker never sees raw SQL.
This lesson teaches the smallest set of tools the Reconciler needs and the boundary that keeps it from doing anything else.
- MCP: Model Context Protocol. A standard way for an agent to call named, parameterised tools instead of running arbitrary code or SQL.
- MCP server: The process that exposes the tools. It owns the database connection. The Worker connects to the MCP server, not to the database.
- MCP tool: A named operation with a fixed input shape and a fixed output shape. Behind it is a parameterised query plus policy and audit.
- Parameterised query: A SQL statement whose values are bound at execution time, not concatenated into a string. The shape of the statement is fixed.
- Tool surface: The full list of tools a Worker can call. Everything outside the list is impossible by construction.
- Principle of least surface area: Expose the smallest tool surface that lets the Worker do its job. Every tool you add is a tool you must defend.
Two Boundaries Compared
The two ways to wire a Worker to a database are radically different in what they allow.
| Boundary | Worker has | Surface area | Failure mode |
|---|---|---|---|
| Raw DATABASE_URL | All of SQL | Infinite | Anything from a typo to a DROP TABLE expenses to a missing WHERE clause |
| Bounded MCP tools | A short list of named tools | Small, named, parameterised | Tool returns an error; database state is safe |
The raw DATABASE_URL boundary trusts the Worker to never write a bad SQL statement. The bounded MCP boundary makes the database refuse to run statements the Worker did not pre-approve. The first is policy in the head. The second is policy in the code.
You can have every discipline in this chapter and still be one bad model output away from a disaster. The MCP boundary removes that whole class of disaster.
The Reconciler's Minimum Tool Surface
Six tools cover the Reconciler's whole job. Three categories.
Read (one call returns rows; no writes):
get_run_status(run_id): returns one row from therunstable.list_my_runs(): returns the recent runs this Worker owns.peek_pending_transactions(limit): returns pending bank rows without claiming them.
Write (one call mutates rows; idempotency and claims are baked in):
record_tool_call(turn_id, tool_name, input, output): appends a row totool_calls(callback to Lesson 2's journal).claim_next_outbox_message(): runs theFOR UPDATE SKIP LOCKEDclaim from Lesson 5 and returns the next pending outbox row.mark_outbox_delivered(message_id): flips the row todispatchedand stampsdispatched_at.
Append-only (one call adds an immutable event):
append_event(run_id, event_type, payload): inserts intoagent_events(callback to Lesson 6).
And just as important, the tools that are NOT on this list.
- No
execute_sql(query). - No
run_migration(path). - No
truncate_table(name). - No
delete_row(table, id).
If a tool is not on the surface, the Worker cannot reach the behaviour. There is no "the model just wrote a SQL string". There is no SQL string in scope.
What an MCP Tool Wraps Under the Hood
You do not have to write the tool implementation. You have to recognise what one looks like.
A tool is three things stacked together.
- A parameterised query: the SQL is fixed; only the values change.
- A policy check: the MCP server uses the right role, sets the tenant context (callback to Lesson 7), and verifies the inputs.
- An audit log entry: the MCP server records that the tool was called, by whom, with what inputs, with what result.
The Worker calling claim_next_outbox_message() sees a JSON response. Inside the MCP server, behind that call, this happens:
- The server starts a transaction as
worker_write. - The server sets
app.current_user_idto the Worker's current tenant. - The server runs the parameterised
SELECT ... FOR UPDATE SKIP LOCKEDagainstoutbox_messages. - The server inserts a row into
agent_eventsrecording the claim. - The server commits.
- The server returns the row data to the Worker as JSON.
Six prior lessons of discipline collapse into one tool call. The Worker did not write any SQL. The Worker did not pick a role. The Worker did not set a tenant context. The Worker did not remember to emit an event. The tool did all of it.
That is the whole point.
Dev MCP vs Prod MCP
In development, agents sometimes get a wide tool surface so they can prototype. The thing you cannot ship is a Worker that talks to production with a development-grade MCP.
| Aspect | Dev MCP | Prod MCP |
|---|---|---|
| DB access | Full DATABASE_URL behind one execute_sql tool | Parameterised, role-scoped tools per operation |
| Logging | Optional | Mandatory audit per call |
| RLS | Often disabled | Enforced |
| Idempotency | Manual (the agent passes a key sometimes) | Wrapped by the tool (the tool builds the key) |
| Tenant context | Test fixtures, hardcoded | SET LOCAL on every call, derived from the request |
| Allowed verbs | Anything the role can do | The minimum set per tool |
The Reconciler in production should not see a single execute_sql. The Reconciler in development might, but only against a Neon branch with synthetic data and a one-Worker scope.
The rule: never wire the production database to a dev MCP. Never wire a Worker to a tool surface wider than its job. A tool surface that grew "for convenience" is a future incident.
What a Tool Definition Looks Like
You read these. You do not write them. Recognition only.
- name: append_event
description: Append an immutable event row to agent_events.
params:
run_id: { type: uuid, required: true }
event_type: { type: string, required: true }
payload: { type: object, required: true }
returns:
event_id: bigint
occurred_at: timestamptz
side_effects:
- inserts one row into agent_events
- inserts one row into tool_audit_log
role: worker_write
raises:
- InvalidRunId
- PolicyViolation
Four things to read.
- The input shape is fixed. Three named parameters, two of them required, one a typed object.
- The return shape is fixed. The Worker gets back exactly two fields.
- The side effects are declared. Two writes happen for every successful call. Reading this is how you know an
append_eventcall leaves both an event row and an audit entry. - The role is named. The tool runs as
worker_write, not as the database owner.
A tool whose definition lists "side_effects: any" or "params: any" is not a tool. It is execute_sql in a costume.
PRIMM-AI+ Practice: Design the Reconciler's Tool Surface
Predict [AI-FREE]
Before reading further or asking the agent, list the smallest set of tools the Reconciler needs. Use the three categories: Read, Write, Append-Only. For each tool, write one sentence describing what raw SQL it wraps.
Then write down:
- The shortest tool surface you could design that still lets the Reconciler do its full job.
- The first tool you would consider adding next, and the specific Worker behaviour that would require it.
- The first tool that looks tempting but you should refuse to add. Why.
- Your confidence score from 1 to 5.
You should be able to answer all three before reading the agent's proposal.
Run
Ask Claude Code to propose the Reconciler's tool surface as a short YAML or JSON list. Constrain the agent: no execute_sql, no run_migration, no truncate_table. After it produces the list, ask it to demonstrate the Reconciler calling claim_next_outbox_message in a session that has no DATABASE_URL in scope.
What you should see: a tool surface of roughly six tools, each with a fixed input shape, a fixed return shape, and a declared role. The demonstration runs through the MCP server. The Worker never holds a database connection.
Investigate
Write your own one-paragraph explanation:
- The six tools cover read, write, and append-only without exposing arbitrary SQL.
- Removing
peek_pending_transactionswould force the Reconciler to useclaim_next_outbox_messageinstead, which marks the row claimed instead of just looking at it. Less peeking, more committing. - The MCP server applies the role and the tenant context on every call. The Worker does not pick either.
- Every write tool inserts both the business row and the audit row. The audit trail is automatic.
Then ask the agent:
- "Which of these six tools could a malicious or buggy Worker still use to leak data, and how would the MCP server stop it?"
- "If I removed
peek_pending_transactionsentirely, what Worker behaviour would change? Walk through the Reconciler's run." - "Name three tools that look useful but should NOT be added to the Reconciler's surface, and the specific failure each one would let in."
The third question is where the principle of least surface area earns its name. update_run_status(run_id, new_status) looks helpful for ops cleanups. It would also let a Worker silently change a failed run to completed. That is not a tool the Reconciler needs.
Modify
Add one new tool to the surface for ops use only: force_complete_run(run_id).
Predict:
- Which role should be allowed to call this tool.
- What audit it must produce when called.
- Why a Worker should never be able to call it, even by accident.
- What the on-call engineer's process should look like before invoking it.
What you should see in the agent's response: force_complete_run runs as worker_admin, not worker_write. Every call writes an agent_events row with event_type = 'run_force_completed' and a payload naming the operator and the reason. The Reconciler's tool list does not include it. The MCP server refuses the call if the requesting Worker is not authenticated as an operator.
Adding an ops tool to the surface is fine. Adding it without the audit and the role separation is how an ops tool becomes a backdoor.
Make [Mastery Gate]
The brief in business English:
"Design the minimal MCP tool surface for the Reconciler so that: (1) the Worker cannot run arbitrary SQL, (2) the Worker cannot read another user's transactions, (3) every write the Worker performs produces an event in
agent_events, and (4) every claim the Worker performs usesFOR UPDATE SKIP LOCKED. Anything outside the tool list must be impossible."
Hand this brief to the agent. Ask for:
- The full tool list (name, params, returns, role).
- A one-line note for each tool naming the lesson it depends on (Lesson 3 for idempotency, Lesson 4 for the claim, Lesson 5 for the outbox, Lesson 6 for the event, Lesson 7 for the tenant context).
- A short paragraph naming what was excluded from the surface and why.
You read each piece. The gate passes when you can:
- Point at the tool list and name what each tool refuses to let the Worker do.
- Read the exclusion paragraph and confirm
execute_sql,run_migration, and any unscoped delete are absent. - Explain in business terms why removing one tool would still let the Worker do its job, and how that supports the principle of least surface area.
If the agent ships execute_sql "for flexibility", you reject. If the agent ships a write tool that does not emit an event, you reject. If the agent ships a tool with params: any, you reject.
Try With AI
Prompt 1: Tool Surface Design
Help me design the smallest MCP tool surface for the Expense Reconciler
Worker. The Worker must be able to read pending bank transactions,
claim and dispatch outbox messages, record its tool calls, and append
events. It must NOT be able to run arbitrary SQL, drop tables, or
read another user's data.
List the tools you would expose. For each one give me:
- name
- params (named and typed)
- returns
- role
- one sentence on what raw SQL it wraps and what it prevents
What you're learning: Designing surfaces is a discipline of subtraction. The right answer is short. Every tool you add is a tool you must defend. The exercise is to start with the job and stop adding the moment the job is covered.
Prompt 2: Dev vs Prod MCP Comparison
I have a dev MCP that exposes one tool called execute_sql which takes
a SQL string. I want to wire a Worker against production. Walk me
through every specific way this is unsafe. For each one, name the
prior lesson (idempotency, claims, outbox, events, RLS) whose
discipline is bypassed by execute_sql.
Then propose the smallest set of prod-grade tools that would replace
execute_sql for the Reconciler's actual job.
What you're learning: The dev MCP is convenient because it does nothing. The prod MCP is small because it does everything for you. The exercise is mapping each prior discipline to a tool that wraps it, so the Worker cannot accidentally skip any of them.
Prompt 3: Principle of Least Surface Area Drill
I will paste a proposed tool surface of nine tools for the Reconciler.
For each tool, tell me whether it belongs on the surface or should be
removed. If you would remove it, name the failure mode it would let in
or the smaller tool that already covers the same job.
1. get_run_status(run_id)
2. list_my_runs()
3. peek_pending_transactions(limit)
4. execute_sql(query)
5. claim_next_outbox_message()
6. mark_outbox_delivered(message_id)
7. append_event(run_id, event_type, payload)
8. record_tool_call(turn_id, tool_name, input, output)
9. update_expense_amount(expense_id, new_amount)
What you're learning: Reviewing a surface is the most common MCP boundary work in real teams. Tool 4 is the deal-breaker; it reintroduces every failure mode the chapter spent eight lessons closing. Tool 9 is the subtle one; it lets a Worker quietly rewrite a committed business fact, which is exactly what idempotency and events were supposed to prevent.
Checkpoint
- I can explain why even a well-disciplined Worker should not hold a raw DATABASE_URL.
- I can read a list of MCP tools and categorise each one as Read, Write, or Append-Only.
- I can name at least three tools that should NOT be on the Reconciler's surface and the failure each one would let in.
- I can compare a dev MCP and a prod MCP and explain why the dev boundary is unsafe to ship.
- I can read an MCP tool definition (params, returns, role, side effects) and confirm whether it preserves the prior lessons' disciplines.
- I rejected at least one agent-proposed tool surface during practice for including
execute_sqlor a write tool that did not emit an event.