feat(mcp): add project cost confirmation elicitation - #391
Conversation
commit: |
Coverage Report for CI Build 33395048111Coverage decreased (-0.3%) to 96.269%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
619ef97 to
29f25db
Compare
| return ( | ||
| typeof value === 'object' && | ||
| value !== null && | ||
| Array.isArray((value as { content?: unknown }).content) |
There was a problem hiding this comment.
Any tool returning { content: [...] } as ordinary data gets passed through raw here. I tried it with a { title, content: [...] } payload and the client rejected the whole result (Invalid tools/call result: invalid_union). The data never arrives.
Since this is published and shared, it hits every consumer. Could we use an explicit discriminant like isInputRequiredResult does, rather than the shape check?
| }; | ||
| } | ||
|
|
||
| if ( |
There was a problem hiding this comment.
This drift check sits above the decline/cancel checks, so a decline gets dropped and re-prompted whenever the quote moved between rounds. Confirmed this locally: declining after a price shift returns input_required instead of {status:'declined'}.
Moving the two action checks above this block should do it.
| confirm_cost: inputRequired.elicit({ | ||
| mode: 'form', | ||
| message: [ | ||
| `Creating this project costs $${cost.amount}/month.`, |
There was a problem hiding this comment.
/month is hardcoded here (and on line 330), but cost.recurrence already tells us 'monthly' vs 'hourly'. Correct today since only create_project is enabled, but nothing type-checks the string, so whenever enabledTools widens to create_branch, this will quietly render an hourly amount as /month. Cheap to read it from cost.recurrence now.
| }; | ||
| } | ||
|
|
||
| return await account.createProject({ |
There was a problem hiding this comment.
Replaying the same requestState + accept creates a project each time: The user gets 2 projects from one consent locally. I see replay prevention is called out as out of scope, so mostly wanted to check that's a deliberate call for Stage A given it's a billable action, and whether the platform PR handles it.
| throw new Error('Cannot create a project in read-only mode.'); | ||
| } | ||
|
|
||
| if (costConfirmation && isFormCapable(ctx)) { |
There was a problem hiding this comment.
We elicit even when the cost is $0, so free-tier creates get a round-trip and a "costs $0/month" prompt. Matches the legacy flow's behaviour, so no regression, but since the point here is smoothing the UX, is skipping the prompt at amount === 0 on the table, or is confirming-regardless intentional?
| } | ||
|
|
||
| const modes = Object.keys(elicitation); | ||
| return modes.length === 0 || modes.includes('form'); |
There was a problem hiding this comment.
Checked this against the 2026-07-28 spec and it's exactly right (spec):
For backwards compatibility, an empty capabilities object is equivalent to declaring support for form mode only:
"elicitation": {}, // Equivalent to { "form": {} }
Clients declaring the elicitation capability MUST support at least one mode (form or url).
Servers MUST NOT send elicitation requests with modes that are not supported by the client.
An empty map is spec'd as { form: {} }.
Only footnote: the reference client's getSupportedElicitationModes treats any map without form/url as form-capable, so { someFutureMode: {} } differs here. We fall back to the legacy path, which is the safer side, and such a client is non-conforming anyway. Fine as-is IMO.
What kind of change does this PR introduce?
Feature.
Implements the
create_projectslice of AI-1091.What is the current behavior?
create_projectrequires a validconfirm_cost_idbefore execution. Clients without elicitation capability keep this flow.What is the new behavior?
When
costConfirmationis configured withenabledTools: ['create_project'], form-capable clients can confirm the quoted cost insidecreate_projectthrough the SDK-nativeinputRequiredandinputResponseflow. The signed request state binds the method and authenticated principal, and carriestool: 'create_project', the project name, region, organization, and quoted cost.The form has no properties. Consent lives in the elicitation action, so clients use their native accept, decline, and cancel controls. Accept creates the project. Decline or cancel returns a normal result without creating anything.
Argument mismatches are rejected, and cost drift starts confirmation again. A state signed for the wrong tool fails before argument or acceptance checks. Empty elicitation mode maps count as form-capable; URL-only or absent elicitation uses the legacy
confirm_costflow. A precomputed legacy ID cannot bypass confirmation for a form-capable request.This PR covers
create_projectonly. It does not change stdio, and there is nocreate_branchimplementation in this PR.How to Review
Server setup
packages/mcp-server-supabase/src/server.tscostConfirmation, itsenabledTools, and request state enter the server.Confirmation flow and executor seam
packages/mcp-server-supabase/src/tools/cost-confirmation.ts,packages/mcp-server-supabase/src/tools/account-tools.ts,packages/mcp-server-supabase/src/tools/util.ts, andpackages/mcp-utils/src/server.tsBehavioral coverage
packages/mcp-server-supabase/src/server.test.tsVerification
pnpm --filter @supabase/mcp-server-supabase test src/server.test.ts -t "create_project cost confirmation via elicitation"passed 8 tests.pnpm --filter @supabase/mcp-server-supabase buildpassed.pnpm test:packed-platform-consumerpassed all 3 public-surface assertions.biome ci .passed.Additional context
This is a fresh, project-only implementation separate from the earlier draft in #389.
When confirmation is configured, the public schema makes
confirm_cost_idoptional so form-capable clients can callcreate_projectdirectly. Capability-free execution still requires a valid ID.Known limits remain: signed state has no single-use replay prevention, and the existing final cost-check concurrency race remains.
The platform PR will first pin the pkg.pr.new preview, then switch to the released npm version.
This implementation was AI-assisted. The verification evidence is above, and human review is required before merge.