Skip to content

feat(mcp): add project cost confirmation elicitation - #391

Open
barryroodt wants to merge 4 commits into
mainfrom
feat/hosted-project-cost-elicitation
Open

feat(mcp): add project cost confirmation elicitation#391
barryroodt wants to merge 4 commits into
mainfrom
feat/hosted-project-cost-elicitation

Conversation

@barryroodt

@barryroodt barryroodt commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Feature.

Implements the create_project slice of AI-1091.

What is the current behavior?

create_project requires a valid confirm_cost_id before execution. Clients without elicitation capability keep this flow.

What is the new behavior?

When costConfirmation is configured with enabledTools: ['create_project'], form-capable clients can confirm the quoted cost inside create_project through the SDK-native inputRequired and inputResponse flow. The signed request state binds the method and authenticated principal, and carries tool: '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_cost flow. A precomputed legacy ID cannot bypass confirmation for a form-capable request.

This PR covers create_project only. It does not change stdio, and there is no create_branch implementation in this PR.

How to Review

  1. Server setup

    • packages/mcp-server-supabase/src/server.ts
    • Inspect how costConfirmation, its enabledTools, and request state enter the server.
  2. 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, and packages/mcp-utils/src/server.ts
    • Inspect the private module that owns the project state discriminator, action-only schema, and capability helper, plus signed state, project creation behavior, request-state verification, and SDK result passthrough.
  3. Behavioral coverage

    • packages/mcp-server-supabase/src/server.test.ts
    • Inspect the modern and legacy paths.
  • Does capability detection select the right lane?
  • Is signed state bound to the right request, tool, and principal?
  • Do decline and cancel avoid project creation?
  • Does the legacy flow remain strict?

Verification

  • 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 build passed.
  • pnpm test:packed-platform-consumer passed 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_id optional so form-capable clients can call create_project directly. 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.

@barryroodt
barryroodt requested a review from a team as a code owner August 28, 2026 19:41
@barryroodt barryroodt added the publish-preview Runs `publish-preview` workflow to publish preview packages via https://pkg.pr.new/ label Aug 28, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: d1e46fd

@coveralls

coveralls commented Aug 28, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 33395048111

Coverage decreased (-0.3%) to 96.269%

Details

  • Coverage decreased (-0.3%) from the base build.
  • Patch coverage: 15 uncovered changes across 2 files (158 of 173 lines covered, 91.33%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/mcp-server-supabase/src/tools/account-tools.ts 127 114 89.76%
packages/mcp-server-supabase/src/tools/cost-confirmation.ts 18 16 88.89%
Total (4 files) 173 158 91.33%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3201
Covered Lines: 3101
Line Coverage: 96.88%
Relevant Branches: 417
Covered Branches: 382
Branch Coverage: 91.61%
Branches in Coverage %: Yes
Coverage Strength: 53.49 hits per line

💛 - Coveralls

@barryroodt
barryroodt force-pushed the feat/hosted-project-cost-elicitation branch from 619ef97 to 29f25db Compare August 31, 2026 08:15
Comment thread packages/mcp-server-supabase/src/tools/account-tools.ts
return (
typeof value === 'object' &&
value !== null &&
Array.isArray((value as { content?: unknown }).content)

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.

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 (

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.

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.`,

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.

/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({

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.

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)) {

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.

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');

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.

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.

@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.

Left some comments

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.

3 participants