diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index de732c3a..9fe870d1 100644 --- a/packages/mcp-server-supabase/src/platform/api-platform.ts +++ b/packages/mcp-server-supabase/src/platform/api-platform.ts @@ -41,6 +41,7 @@ import { type GetLogsOptions, type QueryLogsOptions, type ResetBranchOptions, + type SecretOperations, type StorageConfig, type StorageOperations, type SupabasePlatform, @@ -815,6 +816,30 @@ export function createSupabaseApiPlatform( }, }; + const secrets: SecretOperations = { + async getUpdatedAt(projectId: string, name: string) { + const response = await managementApiClient.GET( + '/v1/projects/{ref}/secrets', + { + params: { + path: { + ref: projectId, + }, + }, + } + ); + + assertSuccess(response, 'Failed to fetch secrets'); + + const secret = response.data.find((s) => s.name === name); + if (!secret || !secret.updated_at) { + return undefined; + } + + return new Date(secret.updated_at); + }, + }; + const platform: SupabasePlatform = { async init(info: InitData) { const { clientInfo } = info; @@ -838,6 +863,7 @@ export function createSupabaseApiPlatform( functions, branching, storage, + secrets, }; return platform; diff --git a/packages/mcp-server-supabase/src/platform/types.ts b/packages/mcp-server-supabase/src/platform/types.ts index b8c3aa2b..4c84afb7 100644 --- a/packages/mcp-server-supabase/src/platform/types.ts +++ b/packages/mcp-server-supabase/src/platform/types.ts @@ -275,6 +275,14 @@ export type BranchingOperations = { rebaseBranch(branchId: string): Promise; }; +/** + * Returns only the `updated_at` timestamp of the named secret, never its + * value or digest. + */ +export type SecretOperations = { + getUpdatedAt(projectId: string, name: string): Promise; +}; + export type SupabasePlatform = { init?(info: InitData): Promise; account?: AccountOperations; @@ -284,4 +292,5 @@ export type SupabasePlatform = { development?: DevelopmentOperations; storage?: StorageOperations; branching?: BranchingOperations; + secrets?: SecretOperations; }; diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 5c6ec72f..07c43394 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -28,6 +28,7 @@ import { mockBranches, mockContentApiSchemaLoadCount, mockProjects, + mockSecrets, setupMockApis, } from '../test/mocks.js'; import { createSupabaseApiPlatform } from './platform/api-platform.js'; @@ -232,6 +233,61 @@ async function setupFormCapable(options: FormCapableSetupOptions = {}) { return { client }; } +/** + * Sets up an MCP client with URL elicitation capability for the + * `create_edge_function_secret` secret-collection elicitation lane. + */ +async function setupUrlCapable(options: FormCapableSetupOptions = {}) { + const { readOnly, projectId, elicitationAction } = options; + + const platform = createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); + + await platform.init?.({ + clientInfo: { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + clientCapabilities: { elicitation: { url: {} } }, + }); + + const handler = createSupabaseMcpHandler({ + platform, + projectId, + readOnly, + costConfirmation: COST_CONFIRMATION, + secretCollection: { + connectBaseUrl: 'https://supabase.com/dashboard/mcp_callback', + }, + }); + + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }); + + const client = new Client( + { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + { + capabilities: { elicitation: { url: {} } }, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + ...(elicitationAction === undefined && { + inputRequired: { autoFulfill: false }, + }), + } + ); + + if (elicitationAction !== undefined) { + client.setRequestHandler('elicitation/create', async () => + elicitationAction === 'accept' + ? { action: 'accept' as const, content: {} } + : { action: elicitationAction } + ); + } + + await client.connect(transport); + + return { client }; +} + describe('init', () => { test('server returns instructions', async () => { const { client } = await setup(); @@ -4333,6 +4389,603 @@ describe('tools', () => { }); }); + describe('create_edge_function_secret via URL elicitation', () => { + test('url-capable client receives InputRequiredResult with url mode', async () => { + const { client } = await setupUrlCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const result = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + expect(isInputRequiredResult(result)).toBe(true); + if (isInputRequiredResult(result)) { + expect(result.inputRequests?.store_secret).toMatchObject({ + method: 'elicitation/create', + params: { + mode: 'url', + url: `https://supabase.com/dashboard/mcp_callback?ref=${encodeURIComponent(project.id)}&name=MY_SECRET`, + }, + }); + + const message = (result.inputRequests?.store_secret?.params as any) + ?.message; + expect(message).toBeDefined(); + const messageLines = message?.split('\n') || []; + expect(messageLines).toHaveLength(3); + expect(message).not.toContain('http'); + } + }); + + test('form-only and empty-capability clients receive isError with no URL', async () => { + for (const capabilities of [ + { elicitation: { form: {} } }, + { elicitation: {} }, + ]) { + const platform = createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); + + await platform.init?.({ + clientInfo: { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + clientCapabilities: capabilities as any, + }); + + const handler = createSupabaseMcpHandler({ + platform, + costConfirmation: COST_CONFIRMATION, + secretCollection: { + connectBaseUrl: 'https://supabase.com/dashboard/mcp_callback', + }, + }); + + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }); + const client = new Client( + { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + { + capabilities: capabilities as any, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + inputRequired: { autoFulfill: false }, + } + ); + + await client.connect(transport); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const result = await client.callTool({ + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + }); + + expect(result.isError).toBe(true); + const textContent = result.content.find((c: any) => c.type === 'text'); + expect((textContent as any)?.text).toContain( + 'This client cannot open a browser page' + ); + expect(JSON.stringify(result)).not.toContain('http'); + } + }); + + test('tool input schema has only project_id and name properties', async () => { + const { client } = await setupUrlCapable(); + + const { tools } = await client.listTools(); + const secretTool = tools.find( + (tool) => tool.name === 'create_edge_function_secret' + ); + + expect(secretTool?.inputSchema.properties).toHaveProperty('project_id'); + expect(secretTool?.inputSchema.properties).toHaveProperty('name'); + expect(secretTool?.inputSchema.properties).not.toHaveProperty('value'); + expect( + Object.keys(secretTool?.inputSchema.properties ?? {}) + ).toHaveLength(2); + }); + + test('name starting with SUPABASE_ is rejected', async () => { + const { client } = await setupUrlCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const result = await client.callTool({ + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'SUPABASE_URL' }, + }); + + expect(result.isError).toBe(true); + const textContent = result.content.find((c: any) => c.type === 'text'); + expect((textContent as any)?.text).toContain('SUPABASE_'); + }); + + test('accept with recent secret returns stored true', async () => { + const { client } = await setupUrlCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const first = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first)) { + throw new Error('expected InputRequiredResult'); + } + + // Simulate the secret being stored + mockSecrets.set(project.id, [ + { + name: 'MY_SECRET', + value: 'secret-value', + updated_at: new Date().toISOString(), + }, + ]); + + const second = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + inputResponses: { + store_secret: { action: 'accept', content: {} }, + }, + requestState: first.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult; + + const textContent = second.content.find((c: any) => c.type === 'text'); + expect((textContent as any)?.text).toContain( + 'Secret MY_SECRET is stored' + ); + expect((second as any).structuredContent?.stored).toBe(true); + }); + + test('accept with old or missing secret reissues elicitation with same issued_at', async () => { + const { client } = await setupUrlCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const first = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first)) { + throw new Error('expected InputRequiredResult'); + } + + // Simulate an old secret + const oldDate = new Date(Date.now() - 700_000); + mockSecrets.set(project.id, [ + { + name: 'MY_SECRET', + value: 'secret-value', + updated_at: oldDate.toISOString(), + }, + ]); + + const second = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + inputResponses: { + store_secret: { action: 'accept', content: {} }, + }, + requestState: first.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + expect(isInputRequiredResult(second)).toBe(true); + if (isInputRequiredResult(second)) { + expect(second.inputRequests?.store_secret).toMatchObject({ + method: 'elicitation/create', + params: { + url: `https://supabase.com/dashboard/mcp_callback?ref=${encodeURIComponent(project.id)}&name=MY_SECRET`, + }, + }); + + // Decode requestState to verify issued_at is preserved + const firstState = JSON.parse( + Buffer.from(first.requestState!.split('.')[1]!, 'base64').toString() + ); + const secondState = JSON.parse( + Buffer.from(second.requestState!.split('.')[1]!, 'base64').toString() + ); + expect(typeof firstState.p.issued_at).toBe('number'); + expect(secondState.p.issued_at).toBe(firstState.p.issued_at); + } + }); + + test('accept with secret updated at exact issue time (second boundary)', async () => { + const { client } = await setupUrlCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + const first = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first)) { + throw new Error('expected InputRequiredResult'); + } + + // Decode issued_at and set mock secret's updated_at to the same value truncated to seconds + const firstState = JSON.parse( + Buffer.from(first.requestState!.split('.')[1]!, 'base64').toString() + ); + expect(firstState.p.issued_at % 1000).toBe(0); + const issuedAtTruncated = new Date( + Math.floor(firstState.p.issued_at / 1000) * 1000 + ); + mockSecrets.set(project.id, [ + { + name: 'MY_SECRET', + value: 'secret-value', + updated_at: issuedAtTruncated.toISOString(), + }, + ]); + + const result = (await client.request({ + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + inputResponses: { + store_secret: { action: 'accept', content: {} }, + }, + requestState: first.requestState, + }, + })) as CallToolResult; + + expect(result.isError).toBeFalsy(); + expect(result.content).toMatchObject([ + { type: 'text', text: `Secret MY_SECRET is stored.` }, + ]); + expect((result as any).structuredContent).toMatchObject({ + name: 'MY_SECRET', + stored: true, + }); + }); + + test('decline and cancel return stored false', async () => { + const { client } = await setupUrlCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + for (const action of ['decline', 'cancel'] as const) { + const first = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first)) { + throw new Error('expected InputRequiredResult'); + } + + const second = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + inputResponses: { + store_secret: { action, content: {} }, + }, + requestState: first.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult; + + expect((second as any).structuredContent?.stored).toBe(false); + } + }); + + test('fresh call with recent secret returns stored true without elicitation', async () => { + const { client } = await setupUrlCapable(); + + const org = await createOrganization({ + name: 'My Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const project = await createProject({ + name: 'Project 1', + region: 'us-east-1', + organization_id: org.id, + }); + project.status = 'ACTIVE_HEALTHY'; + + // Set up a recent secret + const now = Date.now(); + mockSecrets.set(project.id, [ + { + name: 'MY_SECRET', + value: 'secret-value', + updated_at: new Date(now - 500_000).toISOString(), + }, + ]); + + const result = await client.callTool({ + name: 'create_edge_function_secret', + arguments: { project_id: project.id, name: 'MY_SECRET' }, + }); + + expect((result as any).structuredContent?.stored).toBe(true); + expect( + (result as any).structuredContent?.updated_seconds_ago + ).toBeGreaterThan(0); + expect( + (result as any).structuredContent?.updated_seconds_ago + ).toBeLessThan(600); + }); + + test('rejects requestState minted by create_project', async () => { + // Need form+url capabilities: form for create_project, url for create_edge_function_secret + const platform = createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); + + await platform.init?.({ + clientInfo: { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + clientCapabilities: { elicitation: { form: {}, url: {} } }, + }); + + const handler = createSupabaseMcpHandler({ + platform, + costConfirmation: COST_CONFIRMATION, + secretCollection: { + connectBaseUrl: 'https://supabase.com/dashboard/mcp_callback', + }, + }); + + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }); + + const client = new Client( + { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + { + capabilities: { elicitation: { form: {}, url: {} } } as any, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + inputRequired: { autoFulfill: false }, + } + ); + await client.connect(transport); + + const org = await createOrganization({ + name: 'Paid Org', + plan: 'pro', + allowed_release_channels: ['ga'], + }); + const existingProject = await createProject({ + name: 'Existing Project', + region: 'us-east-1', + organization_id: org.id, + }); + existingProject.status = 'ACTIVE_HEALTHY'; + + // Get a requestState from create_project (requires a pro org with existing project) + const projectFirst = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_project', + arguments: { + organization_id: org.id, + name: 'My Project', + region: 'us-east-1', + }, + }, + }, + { allowInputRequired: true } + )) as CallToolResult | InputRequiredResult; + + if (!isInputRequiredResult(projectFirst)) { + throw new Error( + 'expected an input_required result from create_project' + ); + } + + const result = (await client.request( + { + method: 'tools/call', + params: { + name: 'create_edge_function_secret', + arguments: { project_id: existingProject.id, name: 'MY_SECRET' }, + inputResponses: { + store_secret: { action: 'accept', content: {} }, + }, + requestState: projectFirst.requestState, + }, + }, + { allowInputRequired: true } + )) as CallToolResult; + + expect(result.isError).toBe(true); + const textContent = result.content.find((c: any) => c.type === 'text'); + expect((textContent as any)?.text).toBe( + 'Request state was not issued for create_edge_function_secret.' + ); + }); + + test('tool absent when secretCollection not configured', async () => { + const platform = createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); + + await platform.init?.({ + clientInfo: { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + clientCapabilities: { elicitation: { url: {} } }, + }); + + const handler = createSupabaseMcpHandler({ + platform, + costConfirmation: COST_CONFIRMATION, + // secretCollection NOT set + }); + + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }); + + const client = new Client( + { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + { + capabilities: { elicitation: { url: {} } }, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + } + ); + + await client.connect(transport); + + const { tools } = await client.listTools(); + const secretTool = tools.find( + (tool) => tool.name === 'create_edge_function_secret' + ); + + expect(secretTool).toBeUndefined(); + }); + + test('constructing server with secretCollection but no costConfirmation throws', async () => { + const platform = createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); + + expect(() => + createSupabaseMcpServer({ + platform, + secretCollection: { + connectBaseUrl: 'https://supabase.com/dashboard/mcp_callback', + }, + // costConfirmation NOT set + }) + ).toThrow( + 'secretCollection requires costConfirmation (shared requestState codec).' + ); + }); + }); + test('delete branch', async () => { const { callTool } = await setup({ features: ['account', 'branching'], @@ -4993,7 +5646,11 @@ describe('tools', () => { // query_logs). const registryToolNames = Object.keys(supabaseMcpToolSchemas); const serverToolNames = tools.map((t) => t.name); - const conditionallyHiddenToolNames = new Set(['get_logs']); + // Registered only when secretCollection is configured + const conditionallyHiddenToolNames = new Set([ + 'get_logs', + 'create_edge_function_secret', + ]); const extraToolsInRegistry = registryToolNames.filter( (name) => !serverToolNames.includes(name) diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index 117f418d..0ec97d7f 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -15,6 +15,7 @@ import { getDebuggingTools } from './tools/debugging-tools.js'; import { getDevelopmentTools } from './tools/development-tools.js'; import { getDocsTools } from './tools/docs-tools.js'; import { getEdgeFunctionTools } from './tools/edge-function-tools.js'; +import { getSecretTools } from './tools/secret-tools.js'; import { getStorageTools } from './tools/storage-tools.js'; import { writeToolSet } from './tools/tool-schemas.js'; import type { FeatureGroup } from './types.js'; @@ -74,6 +75,21 @@ export type SupabaseMcpServerOptions = { /** Tools that accept a cost-confirmation elicitation. */ enabledTools: readonly ('create_project' | 'create_branch')[]; }; + + /** + * Enables secret collection via URL elicitation for clients that declare + * per-request url-elicitation capability. Requires `costConfirmation` + * (shared `requestState` codec) and `platform.secrets`. Registered under + * the functions feature group. Only URL-capable clients get the tool. + */ + secretCollection?: { + /** + * Base URL of the dashboard page that collects the secret value; + * `?ref=&name=` is appended. Must not contain a query + * string or fragment. + */ + connectBaseUrl: string; + }; }; const DEFAULT_FEATURES: FeatureGroup[] = [ @@ -117,8 +133,15 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { contentApiUrl = 'https://supabase.com/docs/api/graphql', onToolCall, costConfirmation, + secretCollection, } = options; + if (secretCollection && !costConfirmation) { + throw new Error( + 'secretCollection requires costConfirmation (shared requestState codec).' + ); + } + const contentApiClientPromise = createContentApiClient(contentApiUrl, { 'User-Agent': `supabase-mcp/${version}`, }); @@ -136,13 +159,15 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { features ?? availableDefaultFeatures ); - const costConfirmationCodec = costConfirmation?.enabledTools.length - ? createRequestStateCodec({ - key: costConfirmation.requestStateKey, - ttlSeconds: costConfirmation.ttlSeconds, - bind: (ctx) => `${ctx.mcpReq.method}:${costConfirmation.principal}`, - }) - : undefined; + const costConfirmationCodec = + costConfirmation && + (costConfirmation.enabledTools.length > 0 || secretCollection) + ? createRequestStateCodec({ + key: costConfirmation.requestStateKey, + ttlSeconds: costConfirmation.ttlSeconds, + bind: (ctx) => `${ctx.mcpReq.method}:${costConfirmation.principal}`, + }) + : undefined; const server = createMcpServer({ name: 'supabase', @@ -168,8 +193,6 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { }, tools: async () => { const contentApiClient = await contentApiClientPromise; - const tools: Record = {}; - const { account, database, @@ -178,7 +201,9 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { development, storage, branching, + secrets, } = platform; + const tools: Record = {}; if (enabledFeatures.has('docs')) { Object.assign(tools, getDocsTools({ contentApiClient })); @@ -245,6 +270,24 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { Object.assign(tools, getStorageTools({ storage, projectId, readOnly })); } + if ( + secretCollection && + secrets && + costConfirmationCodec && + enabledFeatures.has('functions') + ) { + Object.assign( + tools, + getSecretTools({ + secrets, + projectId, + readOnly, + codec: costConfirmationCodec, + connectBaseUrl: secretCollection.connectBaseUrl, + }) + ); + } + if (readOnly) { for (const [name, tool] of Object.entries(tools)) { if (writeToolSet.has(name)) { diff --git a/packages/mcp-server-supabase/src/tools/cost-confirmation.test.ts b/packages/mcp-server-supabase/src/tools/cost-confirmation.test.ts index b2140d34..861f22a2 100644 --- a/packages/mcp-server-supabase/src/tools/cost-confirmation.test.ts +++ b/packages/mcp-server-supabase/src/tools/cost-confirmation.test.ts @@ -5,7 +5,7 @@ import { } from '@modelcontextprotocol/server'; import { describe, expect, test } from 'vitest'; -import { isFormCapable } from './cost-confirmation.js'; +import { isFormCapable, isUrlCapable } from './cost-confirmation.js'; // Minimal ServerContext stub: only the envelope slice isFormCapable reads. function makeCtx(envelope: Record): ServerContext { @@ -68,3 +68,50 @@ describe('isFormCapable', () => { expect(isFormCapable(makeCtx(envelope))).toBe(expected); }); }); + +describe('isUrlCapable', () => { + test.each([ + { + label: 'url mode only', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { url: {} } }, + }, + expected: true, + }, + { + label: 'form mode only', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } }, + }, + expected: false, + }, + { + label: 'elicitation is an empty object', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: {} }, + }, + expected: false, + }, + { + label: 'no elicitation key in capabilities', + envelope: { + ...validBase(), + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + expected: false, + }, + { + label: 'protocol version meta key absent', + envelope: { + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { url: {} } }, + // PROTOCOL_VERSION_META_KEY intentionally omitted + }, + expected: false, + }, + ])('$label -> $expected', ({ envelope, expected }) => { + expect(isUrlCapable(makeCtx(envelope))).toBe(expected); + }); +}); diff --git a/packages/mcp-server-supabase/src/tools/cost-confirmation.ts b/packages/mcp-server-supabase/src/tools/cost-confirmation.ts index ad0f5977..115f6eb8 100644 --- a/packages/mcp-server-supabase/src/tools/cost-confirmation.ts +++ b/packages/mcp-server-supabase/src/tools/cost-confirmation.ts @@ -35,7 +35,23 @@ export type BranchCostState = { * Signed `requestState` payload for any cost-confirmation elicitation this * server issues, discriminated by `tool`. */ -export type CostConfirmationState = ProjectCostState | BranchCostState; +export type CostConfirmationState = + | ProjectCostState + | BranchCostState + | SecretCollectionState; + +/** + * Signed `requestState` payload for the `create_edge_function_secret` + * secret-collection elicitation, bound to the project and secret name. The + * `issued_at` timestamp is preserved across reissues. + */ +export type SecretCollectionState = { + tool: 'create_edge_function_secret'; + project_id: string; + name: string; + /** Epoch ms, floored to the second; the platform reports updated_at at second precision. */ + issued_at: number; +}; /** * An action-only elicitation: no properties, so the client renders the @@ -69,3 +85,25 @@ export function isFormCapable(ctx: ServerContext): boolean { const modes = Object.keys(elicitation); return modes.length === 0 || modes.includes('form'); } + +/** + * Whether the current request declares per-request url-elicitation + * capability (protocol revision 2026-07-28): an `elicitation` declaration + * with a `url` mode. + */ +export function isUrlCapable(ctx: ServerContext): boolean { + const envelope = ctx.mcpReq.envelope as Record | undefined; + if (typeof envelope?.[PROTOCOL_VERSION_META_KEY] !== 'string') { + return false; + } + + const capabilities = envelope[CLIENT_CAPABILITIES_META_KEY] as + | { elicitation?: Record } + | undefined; + const elicitation = capabilities?.elicitation; + if (elicitation === undefined) { + return false; + } + + return 'url' in elicitation; +} diff --git a/packages/mcp-server-supabase/src/tools/secret-tools.ts b/packages/mcp-server-supabase/src/tools/secret-tools.ts new file mode 100644 index 00000000..5257ad38 --- /dev/null +++ b/packages/mcp-server-supabase/src/tools/secret-tools.ts @@ -0,0 +1,217 @@ +import { + inputRequired, + inputResponse, + type RequestStateCodec, + type ServerContext, +} from '@modelcontextprotocol/server'; +import { z } from 'zod/v4'; +import type { SecretOperations } from '../platform/types.js'; +import { + isUrlCapable, + type CostConfirmationState, +} from './cost-confirmation.js'; +import { injectableTool, type ToolDefs } from './util.js'; + +const RESUME_WINDOW_SECONDS = 600; + +type SecretToolsOptions = { + secrets: SecretOperations; + projectId?: string; + readOnly?: boolean; + codec: RequestStateCodec; + connectBaseUrl: string; +}; + +const createEdgeFunctionSecretInputSchema = z.object({ + project_id: z.string(), + name: z + .string() + .max(256) + .refine((n) => !n.startsWith('SUPABASE_'), { + message: 'Secret names starting with SUPABASE_ are reserved.', + }), +}); + +const createEdgeFunctionSecretOutputSchema = z.object({ + name: z.string().optional(), + stored: z.boolean().optional(), + updated_seconds_ago: z.number().optional(), + status: z.string().optional(), +}); + +export const secretToolDefs = { + create_edge_function_secret: { + description: + 'Creates or updates an Edge Function secret for the project. The user enters the value in the Supabase dashboard; it never passes through the AI client.', + parameters: createEdgeFunctionSecretInputSchema, + outputSchema: createEdgeFunctionSecretOutputSchema, + annotations: { + title: 'Create Edge Function secret', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, +} as const satisfies ToolDefs; + +export function getSecretTools({ + secrets, + projectId, + readOnly, + codec, + connectBaseUrl, +}: SecretToolsOptions) { + const project_id = projectId; + + return { + create_edge_function_secret: injectableTool({ + ...secretToolDefs.create_edge_function_secret, + inject: { project_id }, + execute: async ( + { + project_id, + name, + }: z.infer, + ctx: ServerContext + ) => { + if (readOnly) { + throw new Error('Cannot create a secret in read-only mode.'); + } + + const issue = async (issued_at: number) => + inputRequired({ + inputRequests: { + store_secret: inputRequired.elicitUrl({ + message: [ + `Add the value for secret ${name} in the Supabase dashboard.`, + 'Only continue if you asked your AI client to store this secret.', + 'Return here and confirm once it is stored.', + ].join('\n'), + url: `${connectBaseUrl}?ref=${encodeURIComponent(project_id)}&name=${encodeURIComponent(name)}`, + }), + }, + requestState: await codec.mint( + { + tool: 'create_edge_function_secret', + project_id, + name, + issued_at, + }, + ctx + ), + }); + + const state = ctx.mcpReq.requestState(); + if (!state) { + if (!isUrlCapable(ctx)) { + return { + content: [ + { + type: 'text' as const, + text: 'This client cannot open a browser page. Add the secret in the dashboard under Edge Functions > Secrets.', + }, + ], + structuredContent: { status: 'unsupported_client' }, + isError: true, + }; + } + + const updatedAt = await secrets.getUpdatedAt(project_id, name); + if (updatedAt) { + const ageMs = Date.now() - updatedAt.getTime(); + if (ageMs >= 0 && ageMs <= RESUME_WINDOW_SECONDS * 1000) { + const updated_seconds_ago = Math.floor(ageMs / 1000); + return { + content: [ + { + type: 'text' as const, + text: `Secret ${name} was stored ${updated_seconds_ago} seconds ago.`, + }, + ], + structuredContent: { name, stored: true, updated_seconds_ago }, + }; + } + } + + // Floor to whole seconds: platform reports updated_at at second precision + const issued_at = Math.floor(Date.now() / 1000) * 1000; + return issue(issued_at); + } + + if (state.tool !== 'create_edge_function_secret') { + return { + content: [ + { + type: 'text' as const, + text: 'Request state was not issued for create_edge_function_secret.', + }, + ], + structuredContent: { status: 'error' }, + isError: true, + }; + } + + if (state.project_id !== project_id || state.name !== name) { + return { + content: [ + { + type: 'text' as const, + text: 'Request state arguments do not match the current arguments.', + }, + ], + structuredContent: { status: 'error' }, + isError: true, + }; + } + + const response = inputResponse( + ctx.mcpReq.inputResponses, + 'store_secret' + ); + if (response.kind !== 'elicit') { + return issue(state.issued_at); + } + + if (response.action === 'decline') { + return { + content: [ + { + type: 'text' as const, + text: 'Nothing stored. Ask the user what to change.', + }, + ], + structuredContent: { status: 'declined', stored: false }, + }; + } + + if (response.action !== 'accept') { + return { + content: [ + { + type: 'text' as const, + text: 'Nothing stored. The user cancelled.', + }, + ], + structuredContent: { status: 'cancelled', stored: false }, + }; + } + + const updatedAt = await secrets.getUpdatedAt(project_id, name); + if (updatedAt && updatedAt.getTime() >= state.issued_at) { + return { + content: [ + { + type: 'text' as const, + text: `Secret ${name} is stored.`, + }, + ], + structuredContent: { name, stored: true }, + }; + } + + return issue(state.issued_at); + }, + }), + }; +} diff --git a/packages/mcp-server-supabase/src/tools/tool-schemas.ts b/packages/mcp-server-supabase/src/tools/tool-schemas.ts index 905e7237..9fee71ca 100644 --- a/packages/mcp-server-supabase/src/tools/tool-schemas.ts +++ b/packages/mcp-server-supabase/src/tools/tool-schemas.ts @@ -7,6 +7,7 @@ import { debuggingToolDefs } from './debugging-tools.js'; import { developmentToolDefs } from './development-tools.js'; import { docsToolDefs } from './docs-tools.js'; import { edgeFunctionToolDefs } from './edge-function-tools.js'; +import { secretToolDefs } from './secret-tools.js'; import { storageToolDefs } from './storage-tools.js'; import type { ToolDefs } from './util.js'; @@ -71,6 +72,7 @@ export const supabaseMcpToolSchemas = { ...defsToSchemas(developmentToolDefs), ...defsToSchemas(docsToolDefs), ...defsToSchemas(edgeFunctionToolDefs), + ...defsToSchemas(secretToolDefs), ...defsToSchemas(storageToolDefs), } satisfies Record; @@ -95,9 +97,13 @@ const FEATURE_TOOL_MAP = { development: Object.keys( developmentToolDefs ) as readonly (keyof typeof developmentToolDefs)[], - functions: Object.keys( - edgeFunctionToolDefs - ) as readonly (keyof typeof edgeFunctionToolDefs)[], + functions: [ + ...Object.keys(edgeFunctionToolDefs), + ...Object.keys(secretToolDefs), + ] as readonly ( + | keyof typeof edgeFunctionToolDefs + | keyof typeof secretToolDefs + )[], branching: Object.keys( branchingToolDefs ) as readonly (keyof typeof branchingToolDefs)[], diff --git a/packages/mcp-server-supabase/test/mocks.ts b/packages/mcp-server-supabase/test/mocks.ts index 08c3d5e2..9441e36b 100644 --- a/packages/mcp-server-supabase/test/mocks.ts +++ b/packages/mcp-server-supabase/test/mocks.ts @@ -84,6 +84,10 @@ export type Migration = { export const mockOrgs = new Map(); export const mockProjects = new Map(); export const mockBranches = new Map(); +export const mockSecrets = new Map< + string, + Array<{ name: string; value: string; updated_at: string }> +>(); export const mockContentApiSchemaLoadCount = { value: 0 }; @@ -855,6 +859,17 @@ export const mockManagementApi = [ } ), + /** + * List secrets + */ + http.get<{ projectId: string }>( + `${API_URL}/v1/projects/:projectId/secrets`, + ({ params }) => { + const secrets = mockSecrets.get(params.projectId) ?? []; + return HttpResponse.json(secrets); + } + ), + /** * List storage buckets */ @@ -939,6 +954,7 @@ export function setupMockApis(): SetupServer { mockOrgs.clear(); mockProjects.clear(); mockBranches.clear(); + mockSecrets.clear(); mockContentApiSchemaLoadCount.value = 0; const mockServer = setupServer(...mockContentApi, ...mockManagementApi);