-
Notifications
You must be signed in to change notification settings - Fork 252
feat: Add MCP elicitation for create project and branch tools #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
c51e633
65a5457
681b453
0aed187
b739cfe
9c65d6a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import { tool } from '@supabase/mcp-utils'; | ||
| import { tool, type ToolExecuteContext } from '@supabase/mcp-utils'; | ||
| import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { z } from 'zod'; | ||
| import type { AccountOperations } from '../platform/types.js'; | ||
| import { type Cost, getBranchCost, getNextProjectCost } from '../pricing.js'; | ||
|
|
@@ -10,9 +12,14 @@ const SUCCESS_RESPONSE = { success: true }; | |
| export type AccountToolsOptions = { | ||
| account: AccountOperations; | ||
| readOnly?: boolean; | ||
| server?: Server; | ||
| }; | ||
|
|
||
| export function getAccountTools({ account, readOnly }: AccountToolsOptions) { | ||
| export function getAccountTools({ | ||
| account, | ||
| readOnly, | ||
| server, | ||
| }: AccountToolsOptions) { | ||
| return { | ||
| list_organizations: tool({ | ||
| description: 'Lists all organizations that the user is a member of.', | ||
|
|
@@ -76,11 +83,16 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { | |
| return await account.getProject(id); | ||
| }, | ||
| }), | ||
| get_cost: tool({ | ||
| description: | ||
| 'Gets the cost of creating a new project or branch. Never assume organization as costs can be different for each.', | ||
| get_and_confirm_cost: tool({ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was originally thinking:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In practice having the elicitation in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mattrossman pointed out that users might get confused and might even panic when they see |
||
| description: async () => { | ||
| const clientCapabilities = server?.getClientCapabilities(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The SDK lets us populate it via I figure we'd expose a capabilities option in our
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct me if I'm wrong, but isn't server capabilities different from client capabilities? IIUC |
||
| if (clientCapabilities?.elicitation) { | ||
| return 'Gets the cost of creating a new project or branch and requests user confirmation. Returns a unique ID for this confirmation which must be passed to `create_project` or `create_branch`. Never assume organization as costs can be different for each.'; | ||
| } | ||
| return 'Gets the cost of creating a new project or branch. You must repeat the cost to the user and confirm their understanding before calling `create_project` or `create_branch`. Returns a unique ID for this confirmation which must be passed to `create_project` or `create_branch`. Never assume organization as costs can be different for each.'; | ||
| }, | ||
| annotations: { | ||
| title: 'Get cost of new resources', | ||
| title: 'Get and confirm cost', | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
|
|
@@ -90,48 +102,94 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { | |
| type: z.enum(['project', 'branch']), | ||
| organization_id: z | ||
| .string() | ||
| .describe('The organization ID. Always ask the user.'), | ||
| .describe('The organization ID. Always ask the user.') | ||
| .optional(), | ||
| }), | ||
| execute: async ({ type, organization_id }) => { | ||
| function generateResponse(cost: Cost) { | ||
| return `The new ${type} will cost $${cost.amount} ${cost.recurrence}. You must repeat this to the user and confirm their understanding.`; | ||
| } | ||
| // Get the cost | ||
| let cost: Cost; | ||
| switch (type) { | ||
| case 'project': { | ||
| const cost = await getNextProjectCost(account, organization_id); | ||
| return generateResponse(cost); | ||
| if (!organization_id) { | ||
| throw new Error( | ||
| 'organization_id is required for project cost calculation' | ||
| ); | ||
| } | ||
| cost = await getNextProjectCost(account, organization_id); | ||
| break; | ||
| } | ||
| case 'branch': { | ||
| const cost = getBranchCost(); | ||
| return generateResponse(cost); | ||
| cost = getBranchCost(); | ||
| break; | ||
| } | ||
| default: | ||
| throw new Error(`Unknown cost type: ${type}`); | ||
| } | ||
| }, | ||
| }), | ||
| confirm_cost: tool({ | ||
| description: | ||
| 'Ask the user to confirm their understanding of the cost of creating a new project or branch. Call `get_cost` first. Returns a unique ID for this confirmation which should be passed to `create_project` or `create_branch`.', | ||
| annotations: { | ||
| title: 'Confirm cost understanding', | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: false, | ||
| }, | ||
| parameters: z.object({ | ||
| type: z.enum(['project', 'branch']), | ||
| recurrence: z.enum(['hourly', 'monthly']), | ||
| amount: z.number(), | ||
| }), | ||
| execute: async (cost) => { | ||
| return await hashObject(cost); | ||
|
|
||
| let userDeclinedCost = false; | ||
|
|
||
| // Request confirmation via elicitation if supported | ||
| const clientCapabilities = server?.getClientCapabilities(); | ||
| if (server && clientCapabilities?.elicitation) { | ||
| try { | ||
| const costMessage = | ||
| cost.amount > 0 ? `$${cost.amount} ${cost.recurrence}` : 'Free'; | ||
|
|
||
| const result = await server.request( | ||
| { | ||
| method: 'elicitation/create', | ||
| params: { | ||
| message: `You are about to create a new ${type}.\n\nCost: ${costMessage}\n\nDo you want to proceed?`, | ||
| requestedSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| confirm: { | ||
| type: 'boolean', | ||
| title: 'Confirm Cost', | ||
| description: `I understand the cost and want to create the ${type}`, | ||
| }, | ||
| }, | ||
| required: ['confirm'], | ||
| }, | ||
| }, | ||
| }, | ||
| ElicitResultSchema | ||
| ); | ||
|
|
||
| if (result.action !== 'accept' || !result.content?.confirm) { | ||
| userDeclinedCost = true; | ||
| } | ||
| } catch (error) { | ||
| // If elicitation fails (client doesn't support it), return cost info for manual confirmation | ||
| console.warn( | ||
| 'Elicitation not supported by client, returning cost for manual confirmation' | ||
| ); | ||
| console.warn(error); | ||
| } | ||
| } | ||
|
|
||
| if (userDeclinedCost) { | ||
| throw new Error( | ||
| 'The user declined to confirm the cost. Ask the user to confirm if they want to proceed with the operation or do something else.' | ||
| ); | ||
| } | ||
|
|
||
| // Generate and return confirmation ID | ||
| const confirmationId = await hashObject(cost); | ||
|
|
||
| return { | ||
| ...cost, | ||
| confirm_cost_id: confirmationId, | ||
| message: | ||
| cost.amount > 0 | ||
| ? `The new ${type} will cost $${cost.amount} ${cost.recurrence}. ${clientCapabilities?.elicitation ? 'User has confirmed.' : 'You must confirm this cost with the user before proceeding.'}` | ||
| : `The new ${type} is free. ${clientCapabilities?.elicitation ? 'User has confirmed.' : 'You may proceed with creation.'}`, | ||
| }; | ||
| }, | ||
| }), | ||
| create_project: tool({ | ||
| description: | ||
| 'Creates a new Supabase project. Always ask the user which organization to create the project in. The project can take a few minutes to initialize - use `get_project` to check the status.', | ||
| 'Creates a new Supabase project. Always ask the user which organization to create the project in. Call `get_and_confirm_cost` first to verify the cost and get user confirmation. The project can take a few minutes to initialize - use `get_project` to check the status.', | ||
| annotations: { | ||
| title: 'Create project', | ||
| readOnlyHint: false, | ||
|
|
@@ -150,13 +208,16 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { | |
| required_error: | ||
| 'User must confirm understanding of costs before creating a project.', | ||
| }) | ||
| .describe('The cost confirmation ID. Call `confirm_cost` first.'), | ||
| .describe( | ||
| 'The cost confirmation ID. Call `get_and_confirm_cost` first.' | ||
| ), | ||
| }), | ||
| execute: async ({ name, region, organization_id, confirm_cost_id }) => { | ||
| if (readOnly) { | ||
| throw new Error('Cannot create a project in read-only mode.'); | ||
| } | ||
|
|
||
| // Verify the confirmation ID matches the expected cost | ||
| const cost = await getNextProjectCost(account, organization_id); | ||
| const costHash = await hashObject(cost); | ||
| if (costHash !== confirm_cost_id) { | ||
|
|
@@ -165,11 +226,23 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { | |
| ); | ||
| } | ||
|
|
||
| return await account.createProject({ | ||
| // Create the project | ||
| const project = await account.createProject({ | ||
| name, | ||
| region, | ||
| organization_id, | ||
| }); | ||
|
|
||
| // Return appropriate message based on cost | ||
| const costInfo = | ||
| cost.amount > 0 | ||
| ? `Cost: $${cost.amount}/${cost.recurrence}` | ||
| : 'Cost: Free'; | ||
|
|
||
| return { | ||
| ...project, | ||
| message: `Project "${name}" created successfully. ${costInfo}`, | ||
| }; | ||
| }, | ||
| }), | ||
| pause_project: tool({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import { source } from 'common-tags'; | ||
| import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { z } from 'zod'; | ||
| import { listExtensionsSql, listTablesSql } from '../pg-meta/index.js'; | ||
| import { | ||
|
|
@@ -14,12 +16,14 @@ export type DatabaseOperationToolsOptions = { | |
| database: DatabaseOperations; | ||
| projectId?: string; | ||
| readOnly?: boolean; | ||
| server?: Server; | ||
| }; | ||
|
|
||
| export function getDatabaseTools({ | ||
| database, | ||
| projectId, | ||
| readOnly, | ||
| server, | ||
| }: DatabaseOperationToolsOptions) { | ||
| const project_id = projectId; | ||
|
|
||
|
|
@@ -215,6 +219,44 @@ export function getDatabaseTools({ | |
| throw new Error('Cannot apply migration in read-only mode.'); | ||
| } | ||
|
|
||
| // Try to request user confirmation via elicitation | ||
| if (server) { | ||
| try { | ||
| const result = await server.request( | ||
| { | ||
| method: 'elicitation/create', | ||
| params: { | ||
| message: `You are about to apply migration "${name}" to project ${project_id}. This will modify your database schema.\n\nPlease review the SQL:\n\n${query}\n\nDo you want to proceed?`, | ||
| requestedSchema: { | ||
| type: 'object', | ||
| properties: { | ||
| confirm: { | ||
| type: 'boolean', | ||
| title: 'Confirm Migration', | ||
| description: | ||
| 'I have reviewed the SQL and approve this migration', | ||
| }, | ||
| }, | ||
| required: ['confirm'], | ||
| }, | ||
| }, | ||
| }, | ||
| ElicitResultSchema | ||
| ); | ||
|
Comment on lines
+225
to
+245
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we need to be careful about code flow with elicitations on stateless HTTP. Stateless HTTP is simulating bidirectional communication via requests and responses, but each new request has a brand new state so we need to make sure our mental model matches this in our implementations. IIUC, elicitations are sent back via HTTP response? If yes, the result of the elicitation will be sent as a brand new request to the server and will never land back at this point in the code flow. Perhaps the best way to confirm this is to develop/test against a remote MCP server instead of stdio.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment suggests elicitations don't work with stateless :/ I'd think it run both requests in parallel (the outer tool call and the inner elicitation) but I guess keeping the tool call long-running w/o streaming isn't great.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup, looks like a blocker. From what I can piece together from the spec, server initiated requests need to come from the stateful SSE |
||
|
|
||
| // User declined or cancelled | ||
| if (result.action !== 'accept' || !result.content?.confirm) { | ||
| throw new Error('Migration cancelled by user'); | ||
| } | ||
| } catch (error) { | ||
| // If elicitation fails (client doesn't support it), proceed without confirmation | ||
| // This maintains backwards compatibility | ||
| console.warn( | ||
| 'Elicitation not supported by client, proceeding with migration without confirmation' | ||
| ); | ||
Rodriguespn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| await database.applyMigration(project_id, { | ||
| name, | ||
| query, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Think this is unused now that it's accessing the server via
ToolExecuteContext