Blog

Technical article

Stop Giving Agents execute_sql

How Synapsor turns database access into reviewed business actions over MCP.

Synapsor 9 minute read

The Problem

The fastest way to connect an AI agent to a database is also the most dangerous:

execute_sql(sql: string)

It demos well. The model can inspect a schema, answer a question, and update a record through one flexible tool. It also makes SQL text, table selection, tenant filters, affected rows, and sometimes transaction boundaries part of a model-controlled request.

Authentication does not solve that problem. A valid MCP session can prove who may reach the server without proving that a particular business-state change is safe. A client-side Allow button can confirm that a tool was called without answering the database questions that matter:

  • Which tenant did the server bind from trusted context?
  • Which row did the agent read before proposing the change?
  • Which fields may change, and which fields must stay hidden?
  • What evidence supported the action?
  • Did the row change after the agent read it?
  • Was the request already applied during a retry?
  • Can an operator reconstruct the result later?

Prompt instructions are not an enforcement boundary. Telling a model to "always include the right tenant" or "never update more than one row" leaves the important invariant inside generated text. The database may still enforce permissions and row-level security, and it should. But those controls protect the connection and the database. They do not define a narrow, reviewable interface for the agent.

The Bad Baseline

The risky surface is not limited to a tool literally named execute_sql. These shapes carry the same problem:

run_query(query)
update_any_table(table, columns, where)
delete_order(order_id, tenant_id)
approve_proposal(proposal_id)
commit_proposal(proposal_id)

The first three expose broad database authority. The last two put approval or commit authority back into the same model-facing tool catalog that requested the change.

A read-only database user is a good starting point for read workloads. Use one, along with restricted views and row-level security where appropriate. But read-only access alone does not provide semantic tools, trusted context binding, evidence handles, query audit, or a path from a read to a separately reviewed write.

The missing primitive is not a smarter SQL prompt. It is a smaller unit of authority.

Reviewed Business Actions

Instead of exposing database mechanics, expose actions that match the application domain:

support.inspect_customer(customer_id)
billing.inspect_invoice_status(invoice_id)
support.propose_plan_credit(customer_id, credit_cents, reason)

The model can inspect scoped data and suggest a change. It cannot choose an arbitrary table, arbitrary columns, or raw SQL. It cannot approve or apply its own proposal.

That separation changes the core flow:

agent calls reviewed capability
-> Runner binds trusted tenant/principal context
-> Runner performs a scoped read
-> evidence and query audit are saved
-> an exact proposal diff is saved
-> operator or reviewed local policy approves
-> guarded writeback applies the approved change
-> receipt and replay record the outcome

The source Postgres or MySQL database remains the source of truth. Synapsor Runner controls the model-facing boundary and the transition from a suggestion to durable state.

The Synapsor Contract

Synapsor defines that boundary in a versioned public contract. The open-source surface has three packages:

  • @synapsor/spec defines canonical contexts, capabilities, workflows, policies, evidence, proposals, receipts, and replay metadata.
  • @synapsor/dsl compiles SQL-like authoring syntax into that canonical JSON.
  • @synapsor/runner validates and enforces the supported contract locally, exposes MCP tools, and keeps the local proposal/evidence/replay ledger.

A support credit contract can begin with trusted context:

CREATE AGENT CONTEXT support_agent_context
  BIND tenant_id FROM ENVIRONMENT SYNAPSOR_TENANT_ID REQUIRED
  BIND principal FROM ENVIRONMENT SYNAPSOR_PRINCIPAL REQUIRED
  TENANT BINDING tenant_id
  PRINCIPAL BINDING principal
END

The model does not submit tenant_id as authority. Runner receives it from the configured trusted environment.

The read capability declares the source object, lookup, visible fields, and kept-out fields:

CREATE CAPABILITY support.inspect_customer
  USING CONTEXT support_agent_context
  SOURCE local_postgres
  ON public.customers
  PRIMARY KEY id
  TENANT KEY tenant_id
  LOOKUP customer_id BY id
  ARG customer_id STRING REQUIRED MAX LENGTH 128
  ALLOW READ id, customer_id, plan, invoice_status,
    support_ticket_reason, plan_credit_cents, credit_reason, updated_at
  KEEP OUT card_token, raw_payment_method, internal_risk_score, private_notes
  REQUIRE EVIDENCE
  MAX ROWS 1
END

The proposal capability narrows the write:

CREATE CAPABILITY support.propose_plan_credit
  USING CONTEXT support_agent_context
  SOURCE local_postgres
  ON public.customers
  PRIMARY KEY id
  TENANT KEY tenant_id
  CONFLICT GUARD updated_at
  LOOKUP customer_id BY id
  ARG customer_id STRING REQUIRED MAX LENGTH 128
  ARG credit_cents NUMBER REQUIRED MIN 1
  ARG reason TEXT REQUIRED MAX LENGTH 500
  PROPOSE ACTION grant_plan_credit
  ALLOW WRITE plan_credit_cents, credit_reason
  PATCH plan_credit_cents = ARG credit_cents
  PATCH credit_reason = ARG reason
  BOUND plan_credit_cents 1..50000
  APPROVAL ROLE support_reviewer
  AUTO APPROVE WHEN plan_credit_cents <= 2500
  WRITEBACK DIRECT SQL
END

The workflow declaration groups the reviewed surface without claiming a full local workflow DAG engine:

CREATE AGENT WORKFLOW support.plan_credit_request
  USING CONTEXT support_agent_context
  ALLOW CAPABILITY support.inspect_customer
  ALLOW CAPABILITY support.propose_plan_credit
  REQUIRE EVIDENCE
  APPROVAL REQUIRED ROLE support_reviewer
  CHECKPOINT PROPOSAL ONLY
END

The DSL is reviewable in git. It compiles to synapsor.contract.json, which Runner and Synapsor Cloud validate against the same public contract subset.

Local-First Runner

The first command requires no account, database, Docker daemon, or MCP client:

npx -y @synapsor/runner demo --quick

The guided terminal path shows semantic tools, a proposal diff, the unchanged source-database state, and local evidence/replay records. It is a teaching fixture rather than a connectivity test.

For a real database, start with staging or a disposable instance:

export DATABASE_URL="<read-only staging database URL>"
npx -y -p @synapsor/runner synapsor-runner start --from-env DATABASE_URL

Before connecting an agent, preview the model-facing surface:

synapsor-runner contract validate ./synapsor.contract.json
synapsor-runner tools preview \
  --config ./synapsor.runner.json \
  --store ./.synapsor/local.db

A healthy preview lists reviewed business capabilities and explicitly confirms that raw SQL, approval, commit, database URL, write credential, and model-controlled tenant tools are absent.

Runner supports MCP over stdio and spec-compatible Streamable HTTP. Claude and Cursor can use dotted capability names directly. OpenAI Agents SDK integrations can use Runner's alias mode to expose function-safe names while retaining the canonical dotted name in metadata.

Proposal Is Not Commit

The flagship support-plan-credit example demonstrates three outcomes:

  1. A $25 request is approved by the reviewed local policy, but the database still does not change until explicit writeback.
  2. A $100 request remains pending until an operator approves it outside MCP.
  3. A $1000 request exceeds the contract bound and is rejected before a proposal row exists.

For accepted proposals, Runner saves the exact before/after fields and an evidence handle. Direct writeback remains intentionally narrow: one guarded row update with tenant, primary-key, allowed-column, expected-version, affected-row, and idempotency checks.

If the row changes between proposal and apply, the expected-version guard returns a conflict instead of overwriting newer state. If the same approved operation is retried, the receipt provides an idempotency boundary.

One-row update to an existing row -> Runner can apply it directly.

Insert a row, touch multiple tables, or emit an event -> use an app-owned executor that rechecks tenant, expected version, allowed action, and idempotency. Runner sends the approved action to that handler; it does not pretend a richer transaction is a safe generic SQL operation.

Cloud Registry

Local contracts can be pushed to Synapsor Cloud:

export SYNAPSOR_CLOUD_BASE_URL="https://synapsor.ai"
export SYNAPSOR_CLOUD_TOKEN="<workspace-scoped-token>"
export SYNAPSOR_WORKSPACE_ID="<workspace_id>"

synapsor-runner cloud push ./synapsor.contract.json \
  --name support-plan-credit

Runner validates and normalizes locally before upload. Cloud authenticates the caller, authorizes project access, validates again server-side, computes a stable digest, and stores an immutable version with actor and source-package metadata. Identical pushes are idempotent; changed contracts create a new version.

The current design-partner UI shows the contract, versions, contexts, capabilities, workflows, policy references, visible and kept-out fields, warnings, and registry audit events. A selected version can be downloaded as a Runner bundle containing the normalized contract, local Runner config, placeholder environment file, README, and seven MCP client templates. The bundle contains environment-variable names, not database passwords or live API tokens.

This is a beta boundary, not an enterprise-readiness claim. Managed runner fleets, SAML/SCIM lifecycle, legal hold, enterprise SLA, full local workflow DAG execution, and hosted enforcement of every authored policy are not part of this release.

What Is Open And What Is Cloud

The open-source Apache-2.0 repository includes:

  • the canonical spec and SQL-like DSL;
  • the local Runner and MCP server;
  • local evidence, query audit, proposals, approval/apply, receipts, and replay;
  • schema inspection, onboarding, conformance fixtures, MCP audit, and examples;
  • real Cloud push and local Runner-bundle consumption.

Synapsor Cloud currently adds:

  • an authenticated, project-isolated contract registry;
  • immutable versioning, digests, source versions, and registry audit;
  • a workspace contract UI;
  • Cloud-to-local Runner bundle generation;
  • existing hosted activity/evidence/replay surfaces where enabled for a design-partner pilot.

The public contract is the compatibility boundary. Cloud and the proprietary C++ engine implement the shared supported subset without making that engine a requirement for local OSS use.

Try It

Start with the no-account demo:

npx -y @synapsor/runner demo --quick

Then run the complete example:

The next product work is operational rather than a change to the safety model: managed runners, stronger enterprise identity lifecycle, contract-version to run/evidence joins, semantic version diff, retention/legal-hold controls, and additional end-to-end examples.

The central rule stays small:

Give the model business tools and proposal authority. Keep approval and durable database authority outside the model-facing interface.

Start without a Cloud account

Run the local quick demo, then try one reviewed action against staging.

View npm package