Skip to content

feat(mcp)!: confirm destructive SQL via elicitation and rename costConfirmation option - #408

Open
barryroodt wants to merge 10 commits into
mainfrom
feat/destructive-sql-confirmation
Open

feat(mcp)!: confirm destructive SQL via elicitation and rename costConfirmation option#408
barryroodt wants to merge 10 commits into
mainfrom
feat/destructive-sql-confirmation

Conversation

@barryroodt

@barryroodt barryroodt commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Why

Adds confirmation before destructive SQL, such as dropping a table (AI-1186).

What changed

Breaking public option rename: costConfirmationconfirmation. enabledTools supports existing cost confirmations (create_project, create_branch) and SQL confirmations (execute_sql, apply_migration); cost discovery is unchanged. Local HTTP opts into both SQL tools. Signed request-state is reusable within its TTL, without exactly-once guarantees. A bounded fix detects direct ALTER TABLE DROP [COLUMN]. No new dependencies.

How to test

With pnpm 10, dependencies installed and packages/mcp-server-supabase/.env.local present (create empty only if absent), run from the repo root:

pnpm --filter @supabase/mcp-server-supabase exec vitest run --project unit src/transports/local-http-entry.test.ts
pnpm --filter @supabase/mcp-server-supabase exec vitest run --project unit src/server.test.ts -t 'declining bare-column DROP'

Expect passing SQL prompt/accept and no-form cases; declining bare-column DROP preserves the column and data. These are isolated loopback/MSW/PGlite fixtures, not real operations.

Trade-offs

Best-effort regex is not a security barrier: literal-comment, UPDATE-comment and WITH/DO misses remain. Without per-request form capability, SQL can execute unprompted. Hosted-platform SQL opt-in is separate (draft #38211), default off. Real GREEN behavior is untested.

…tion option

Extract the duplicated requestState decode/tool/args/decline/cancel/drift
block from create_project and create_branch into checkConfirmationState in
tools/confirmation.ts (renamed from cost-confirmation.ts), validate the
decoded state with zod safeParse, and rename the server option
costConfirmation -> confirmation ahead of non-cost confirmations.

BREAKING CHANGE: `createSupabaseMcpServer` option `costConfirmation` is now `confirmation`.
Port Studio's regex-based destructive SQL check (DROP, DELETE, TRUNCATE,
ALTER TABLE ... DROP COLUMN incl. EXECUTE-string variants, and UPDATE
without WHERE) with comment stripping into tools/destructive-sql.ts, along
with the Studio test cases that pin its false-positive and false-negative
contract. No new dependencies.
When the `confirmation` option enables them and the client declares form
elicitation, execute_sql and apply_migration detect destructive SQL (DROP,
DELETE, TRUNCATE, ALTER TABLE ... DROP COLUMN, UPDATE without WHERE, ported
from Studio) and require an accepted action-only elicitation, bound by a
signed requestState to the project and a hash of the query, before running.
Read-only servers, capability-free clients and non-destructive SQL are
unchanged.
@coveralls

coveralls commented Sep 3, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34487004981

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.5%) to 96.925%

Details

  • Coverage increased (+0.5%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (380 of 382 lines covered, 99.48%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/mcp-server-supabase/src/tools/confirmation.ts 135 133 98.52%
Total (7 files) 382 380 99.48%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3759
Covered Lines: 3664
Line Coverage: 97.47%
Relevant Branches: 566
Covered Branches: 528
Branch Coverage: 93.29%
Branches in Coverage %: Yes
Coverage Strength: 65.5 hits per line

💛 - Coveralls

Brings in #411 (ctx-aware tools(), hidden get_cost/confirm_cost and
confirm_cost_id omission for form-capable clients) and the 0.12.0 release.
Conflicts resolved so #411's per-request tool shaping runs under the renamed
`confirmation` option; #408's tests migrated from setupFormCapable to
setupModern with explicit FORM_CAPABLE capabilities.
@barryroodt
barryroodt marked this pull request as ready for review September 7, 2026 13:03
@barryroodt
barryroodt requested a review from a team as a code owner September 7, 2026 13:03
@mattrossman

Copy link
Copy Markdown
Collaborator

Where do you see controls for turning elicitations on/off fitting in to this?

To me, destructive SQL confirmations get into territory where I could see elicitations getting in the way for users who want agents to auto approve safe actions. Wondering if we should be adding options to control elicitation behavior before / alongside this change.

@barryroodt

Copy link
Copy Markdown
Contributor Author

Where do you see controls for turning elicitations on/off fitting in to this?

To me, destructive SQL confirmations get into territory where I could see elicitations getting in the way for users who want agents to auto approve safe actions. Wondering if we should be adding options to control elicitation behavior before / alongside this change.

that's a good question
we also have the scenario where users will want to disable elicitations (or specific elicitations) for CI workflows.
this is something I had in my first draft but dropped to keep initial complexity down.

We have two main options imo:

  1. query param - if supplied can be used to disable 1 or more elicitations
  2. machine auth tokens - if token is minted with a specific set of perms, elicitations can be bypassed

Open to suggestions of course - and more than happy to add on to either this PR or as a second, stacked PR.

Copy link
Copy Markdown
Collaborator

Query param sounds reasonable. At first I assumed this would just be a boolean flag, but you bring up a good point that we could make it more fine-grained so users can decide what level of safety they want.

Copy link
Copy Markdown
Collaborator

It could be a passthrough of the tools where confirmations are enabled e.g.

?confirmations=execute_sql,apply_migration

The risk with an allowlist is if we add a new tool in the future that uses elicitations for approval, the user won't realize they opted out of its safety net. A deny-list would be safer, so they're only taking in risk in areas they've listed explicitly:

?skip_confirmations=execute_sql,apply_migration

@barryroodt

Copy link
Copy Markdown
Contributor Author

It could be a passthrough of the tools where confirmations are enabled e.g.

?confirmations=execute_sql,apply_migration

The risk with an allowlist is if we add a new tool in the future that uses elicitations for approval, the user won't realize they opted out of its safety net. A deny-list would be safer, so they're only taking in risk in areas they've listed explicitly:

?skip_confirmations=execute_sql,apply_migration

Agreed 💯 , a per-tool deny-list is exactly what I was thinking.

Since the package already exposes the per-tool mechanism, this doesn't need an MCP package change; the Platform just needs to parse the user's preference and derive the effective tool set. We'll pick that up on the Platform side shortly.

@Rodriguespn Rodriguespn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asking for changes as I identified some edge cases on the parsing that I think would need further discussion.

For example, a two-line SQL statement that starts with a comment (--) isn't sanitized correctly today:

SELECT '--';
DROP TABLE films;

I tried this locally against the current regex and the table gets dropped with no confirmation prompt. The -- inside the string literal on line 1 is treated as a real comment and silently eats the rest of that line, including the ; that would otherwise separate it from the DROP.

Any reason why we're doing our own parsing instead of using the official Postgres parser libpg-query-node?

We're already using it on the backend to classify execute_sql queries: sql-classification.worker.ts.

Same scenario through the real parser:

import { parse } from '@libpg-query/parser'

const { stmts } = await parse("SELECT '--';\nDROP TABLE films;")
// stmts.length === 2
// stmts[1].stmt -> { DropStmt: { removeType: 'OBJECT_TABLE', objects: [...films] } }

Two real statements come out and the DROP is fully visible, no regex needed, because the parser understands string literals natively.

I found a few other cases the current regex misses the same way:

  1. UPDATE without a WHERE when a comment sits nearby (leading, inline, or trailing),
  2. ALTER TABLE t DROP col without the optional COLUMN keyword,
  3. destructive statements wrapped in WITH ... or DO $$ ... $$.

All of these come for free with a real parser (checking the whereClause field, matching on the AlterTableCmd subtype instead of literal text, or just walking the statement list) instead of patching the regex edge case by edge case.

I've prepared some test cases that we can reuse here when I was working on the sql parsing.

Lmk what you think.

@barryroodt

Copy link
Copy Markdown
Contributor Author

Asking for changes as I identified some edge cases on the parsing that I think would need further discussion.

For example, a two-line SQL statement that starts with a comment (--) isn't sanitized correctly today:

SELECT '--';
DROP TABLE films;

I tried this locally against the current regex and the table gets dropped with no confirmation prompt. The -- inside the string literal on line 1 is treated as a real comment and silently eats the rest of that line, including the ; that would otherwise separate it from the DROP.

Any reason why we're doing our own parsing instead of using the official Postgres parser libpg-query-node?

We're already using it on the backend to classify execute_sql queries: sql-classification.worker.ts.

Same scenario through the real parser:

import { parse } from '@libpg-query/parser'

const { stmts } = await parse("SELECT '--';\nDROP TABLE films;")
// stmts.length === 2
// stmts[1].stmt -> { DropStmt: { removeType: 'OBJECT_TABLE', objects: [...films] } }

Two real statements come out and the DROP is fully visible, no regex needed, because the parser understands string literals natively.

I found a few other cases the current regex misses the same way:

  1. UPDATE without a WHERE when a comment sits nearby (leading, inline, or trailing),
  2. ALTER TABLE t DROP col without the optional COLUMN keyword,
  3. destructive statements wrapped in WITH ... or DO $$ ... $$.

All of these come for free with a real parser (checking the whereClause field, matching on the AlterTableCmd subtype instead of literal text, or just walking the statement list) instead of patching the regex edge case by edge case.

I've prepared some test cases that we can reuse here when I was working on the sql parsing.

Lmk what you think.

I've been hesitant to add another runtime dependency to the existing package.
I also thought that for the initial version we could re-use the regex implemented in Studio in order to maintain parity - with the idea of migrating to something a bit more robust in future iterations.

Happy to revisit this of course - what are your thoughts on adding this dependency?

@barryroodt barryroodt added the publish-preview Runs `publish-preview` workflow to publish preview packages via https://pkg.pr.new/ label Sep 9, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/@supabase/mcp-server-postgrest@ba61fba
pnpm add https://pkg.pr.new/@supabase/mcp-server-supabase@ba61fba
pnpm add https://pkg.pr.new/@supabase/mcp-utils@ba61fba

commit: ba61fba

Rodriguespn

This comment was marked as resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

publish-preview Runs `publish-preview` workflow to publish preview packages via https://pkg.pr.new/

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants