feat: URL-mode secret collection for create_edge_function_secret - #412
feat: URL-mode secret collection for create_edge_function_secret#412barryroodt wants to merge 11 commits into
Conversation
Coverage Report for CI Build 34456445694Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage decreased (-0.08%) to 96.317%Details
Uncovered Changes
Coverage Regressions2 previously-covered lines in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
commit: |
…collection # Conflicts: # packages/mcp-server-supabase/src/server.test.ts
The dashboard route puts the project ref in the path
(/dashboard/project/{ref}/mcp/secrets), so the connect URL is now a
template with {ref} and {name} placeholders instead of a base URL plus
appended query parameters.
The signing fields now live in one `elicitation` block. `requestState`
is required inside it, so the type guarantees every elicitation feature
has the signed-state config. The runtime guard "secretCollection
requires costConfirmation" and its test are removed.
Before (0.12.0):
costConfirmation: {
requestStateKey, principal, ttlSeconds?, enabledTools
}
After:
elicitation: {
requestState: { key, principal, ttlSeconds? },
costConfirmation?: { enabledTools },
secretCollection?: { connectUrlTemplate },
}
Runtime behaviour for existing callers is unchanged: one HMAC codec
created lazily when a feature that mints state is enabled, bound to
method + principal, with TTL, tool/project/name scoping and reissue
semantics as before. Removing the guard also allows a new shape: a
host can now enable URL-mode secret collection without cost
confirmation.
BREAKING CHANGE: costConfirmation.requestStateKey, .principal and
.ttlSeconds are removed. Pass elicitation.requestState and keep
enabledTools under elicitation.costConfirmation. Hosted callers migrate
with their package bump; the exact 0.12.0 pin protects them until then.
`replace: true` skips the 10 minute resume-by-key shortcut and issues the dashboard URL elicitation again. Default false keeps the current behaviour: a recent update is reported as stored without asking again. The unsupported-client check and accept/reissue recovery are unchanged, and `replace` is not part of the signed state. Result text changes: - decline and cancel: "Not confirmed. If you already saved it in the dashboard, it is stored." The structured result drops `stored: false` and keeps `status`. - accept: "The dashboard reports an update to <name> since this request." - fresh call within 10 minutes: "The dashboard reports an update to <name> <N> seconds ago." Other structured results are unchanged. One focused test covers `replace: true`.
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Initial build of the URL mode interstitial. Has complete mock data to be able to view different states (this will be stripped out in the end). A starting point for us to use as an intercept. This is linked with @barryroodt ticket in the MCP project. supabase/mcp#412 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added an authenticated MCP Secrets page for securely storing API secrets requested by AI tools. - Added provider-aware guidance for OpenAI, Anthropic, Resend, and Stripe keys, including validation warnings and dashboard links. - Added clear success, cancellation, expiration, error, and wrong-account states with account switching. - Added loading placeholders, secret visibility controls, overwrite warnings, and accessibility announcements. - **Bug Fixes** - Improved interstitial animations to respect reduced-motion preferences. - Preserved return destinations and related parameters during sign-in flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Couple feedback items on the UX side, neither seems blocking for this MCP-side code, just unexpected: First, the UI says:
That wording seems specific to the MCP Inspector's UI, are we sure other clients will show the same button, or even require user confirmation? Seems like this should be more generic. Second, I notice that if I click "Reopen in browser" after submitting a secret, it allows me to input the secret again. That seems innocent from a security POV, but it is a little confusing UX because I can't tell from looking at the page if the elicitation was fulfilled (other than the warning about overwriting an existing secret). I'd expect this flow to feel similar to one-time login links, where you get feedback about whether a link was already used. I see there is a placeholder |
mattrossman
left a comment
There was a problem hiding this comment.
I tested this in the MCP Inspector since AFACT no current coding agents implement URL mode elicitations w/ modern protocol properly, and it generally worked as expected 👍
As long as it's behind a flag for us to test first I'm okay to proceed, comments above are generally non blocking but some are worth discussing.
| * server issues, discriminated by `tool`. | ||
| */ | ||
| export type CostConfirmationState = ProjectCostState | BranchCostState; | ||
| export type CostConfirmationState = |
There was a problem hiding this comment.
Should this be called CostConfirmationState still, if it's for more than costs?
| } | ||
| }); | ||
|
|
||
| test('tool input schema exposes project_id, name and replace, never value', async () => { |
There was a problem hiding this comment.
Nit, seems like this could be tested more clearly w/ a single assertion of the strict list of properties, e.g. expect(Object.keys(secretTool?.inputSchema.properties ?? {}).sort()).toEqual(['name', 'project_id', 'replace']);
Asserting that there isn't a "value" property doesn't add much if someone used a different property name like "secret".
|
|
||
| const first = (await client.request( | ||
| { | ||
| method: 'tools/call', |
There was a problem hiding this comment.
Should we be changing the callTool() helper to shorten these tests? Seems like we could use that halper to avoid the raw JSON-RPC stuff (same for cost confirmation tests)
There was a problem hiding this comment.
We're due to condense this file overall and that can be a follow up thing, I'm just trying to find ways to make it easier to review as 1000+ LOC of tests in the PR is a little hard to meaningfully vet.
| .boolean() | ||
| .optional() | ||
| .describe( | ||
| 'Set to true to ask the user for a new value even if this secret was updated in the last 10 minutes. Default false: a recent update is reported as stored without asking again.' |
There was a problem hiding this comment.
What's the rationale for the 10 minute cutoff here? IIUC this means if we run the tool with replace: false, it can still replace an existing value that's older than 10 minutes. I'd expect this flag to behave like a regular upsert flag, where replace: false means I only write the value if it wasn't set.
| headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), | ||
| }); | ||
| expect(response.status).toBe(500); |
There was a problem hiding this comment.
Nit, should we be rejecting invalid template URL on server start instead of request time?
| content: [ | ||
| { | ||
| type: 'text' as const, | ||
| text: 'Request state arguments do not match the current arguments.', |
There was a problem hiding this comment.
Could use test coverage here
| ].join(' '); | ||
| return elicitation === undefined | ||
| ? description | ||
| : `${description} elicitation=${elicitation}`; |
There was a problem hiding this comment.
Is this valuable to have on each line? Wondering if that's a leftover debugging artifact, or maybe it could be filtered to only show on tools that use elicitations. Otherwise seems like dev noise for most tools.



Why
Add an Edge Function’s third-party API key without pasting it into the conversation.
What changed
Secret values are entered only in Dashboard, never the AI client/model. HTTP enables this by default. Discovery requires modern per-request URL capability; existing functions, read-only, auth and platform-support gates remain. Breaking: migrate
costConfirmation.requestStateKey,costConfirmation.principal,costConfirmation.ttlSecondstoelicitation.requestState:{key,principal,ttlSeconds?}.elicitation.costConfirmationandelicitation.secretCollectionremain independent.How to test
Follow setup and run
pnpm dev:http. Use a test project, Dashboard authentication/write permission and the matching deployed Dashboard route. Connect Inspector 2.5.0 Modern (URL-capable) tohttp://127.0.0.1:3111/mcp?project_ref=REF&read_only=falsewithAuthorization: Bearer <PAT>; omitfeatures. Callcreate_edge_function_secretwith a freshname(no value). Save only in Dashboard, return to Inspector and accept within 120s, expectstored:true. Legacy/non-URL clients should not list the tool.Trade-offs
HTTP signed state lasts 120s. Separate timestamp-based fresh-call recovery lasts 600s; this is not URL validity. Acceptance checks
updated_at>=issued_at;stored:trueis not a receipt; another collaborator’s write can satisfy it. Recovery can return recent success; a fresh name avoids that shortcut. Custom API origins require--secret-url-template.