diff --git a/packages/mcp-server-supabase/src/management-api/types.ts b/packages/mcp-server-supabase/src/management-api/types.ts index e27d76b..2103670 100644 --- a/packages/mcp-server-supabase/src/management-api/types.ts +++ b/packages/mcp-server-supabase/src/management-api/types.ts @@ -2940,13 +2940,18 @@ export interface components { /** @enum {string} */ status: "stored" | "applied"; }; + /** @example { + * "root_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + * } */ PgsodiumConfigResponse: { + /** @description The pgsodium root key: 32 bytes, hex-encoded (64 characters). */ root_key: string; }; /** @example { - * "root_key": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + * "root_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" * } */ UpdatePgsodiumConfigBody: { + /** @description The pgsodium root key: 32 bytes, hex-encoded (64 characters). */ root_key: string; }; PostgrestConfigWithJWTSecretResponse: { @@ -3039,6 +3044,22 @@ export interface components { status: "not-used" | "custom-domain-used" | "active"; custom_domain?: string; }; + PlanGateErrorBody: { + /** @description Human-readable explanation of the plan gate */ + message: string; + /** @description Present on entitlement denials. Other errors with this status code (validation, billing state) carry only message. */ + error?: { + /** + * @description Machine-readable marker for plan-gated denials + * @enum {string} + */ + code: "entitlement_required"; + /** @description Entitlement feature key that failed the check */ + feature: string; + /** @description Billing page URL for the organization, present when the org is resolvable */ + upgrade_url?: string; + }; + }; /** @example { * "vanity_subdomain": "acme-prod" * } */ @@ -4780,7 +4801,6 @@ export interface components { CreateProviderResponse: { id: string; saml?: { - id: string; entity_id: string; metadata_url?: string; metadata_xml?: string; @@ -4798,7 +4818,6 @@ export interface components { name_id_format?: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" | "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; }; domains?: { - id: string; domain?: string; created_at?: string; updated_at?: string; @@ -4810,7 +4829,6 @@ export interface components { items: { id: string; saml?: { - id: string; entity_id: string; metadata_url?: string; metadata_xml?: string; @@ -4828,7 +4846,6 @@ export interface components { name_id_format?: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" | "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; }; domains?: { - id: string; domain?: string; created_at?: string; updated_at?: string; @@ -4840,7 +4857,6 @@ export interface components { GetProviderResponse: { id: string; saml?: { - id: string; entity_id: string; metadata_url?: string; metadata_xml?: string; @@ -4858,7 +4874,6 @@ export interface components { name_id_format?: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" | "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; }; domains?: { - id: string; domain?: string; created_at?: string; updated_at?: string; @@ -4893,7 +4908,6 @@ export interface components { UpdateProviderResponse: { id: string; saml?: { - id: string; entity_id: string; metadata_url?: string; metadata_xml?: string; @@ -4911,7 +4925,6 @@ export interface components { name_id_format?: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" | "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; }; domains?: { - id: string; domain?: string; created_at?: string; updated_at?: string; @@ -4922,7 +4935,6 @@ export interface components { DeleteProviderResponse: { id: string; saml?: { - id: string; entity_id: string; metadata_url?: string; metadata_xml?: string; @@ -4940,7 +4952,6 @@ export interface components { name_id_format?: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" | "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; }; domains?: { - id: string; domain?: string; created_at?: string; updated_at?: string; @@ -7940,7 +7951,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PlanGateErrorBody"]; + }; }; /** @description Unauthorized */ 401: { @@ -8049,7 +8062,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PlanGateErrorBody"]; + }; }; /** @description Unauthorized */ 401: { @@ -8110,7 +8125,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PlanGateErrorBody"]; + }; }; /** @description Unauthorized */ 401: { @@ -8430,7 +8447,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PlanGateErrorBody"]; + }; }; /** @description Forbidden action */ 403: { @@ -12909,7 +12928,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PlanGateErrorBody"]; + }; }; /** @description Forbidden action */ 403: { @@ -12984,7 +13005,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PlanGateErrorBody"]; + }; }; /** @description Forbidden action */ 403: { diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index 0862ac2..628978a 100644 --- a/packages/mcp-server-supabase/src/platform/api-platform.ts +++ b/packages/mcp-server-supabase/src/platform/api-platform.ts @@ -20,6 +20,7 @@ import { deployEdgeFunctionOptionsSchema, executeSqlOptionsSchema, getLogsOptionsSchema, + queryLogsOptionsSchema, resetBranchOptionsSchema, type AccountOperations, type ApiKey, @@ -38,6 +39,7 @@ import { type EdgeFunctionWithBody, type ExecuteSqlOptions, type GetLogsOptions, + type QueryLogsOptions, type ResetBranchOptions, type StorageConfig, type StorageOperations, @@ -277,6 +279,30 @@ export function createSupabaseApiPlatform( return response.data; }, + async queryLogs(projectId: string, options: QueryLogsOptions) { + const { sql, iso_timestamp_start, iso_timestamp_end } = + queryLogsOptionsSchema.parse(options); + + const response = await managementApiClient.GET( + '/v1/projects/{ref}/analytics/endpoints/logs', + { + params: { + path: { + ref: projectId, + }, + query: { + sql, + iso_timestamp_start, + iso_timestamp_end, + }, + }, + } + ); + + assertSuccess(response, 'Failed to query logs'); + + return response.data; + }, async getSecurityAdvisors(projectId: string) { const response = await managementApiClient.GET( '/v1/projects/{ref}/advisors/security', diff --git a/packages/mcp-server-supabase/src/platform/types.ts b/packages/mcp-server-supabase/src/platform/types.ts index 0021a9a..276e68a 100644 --- a/packages/mcp-server-supabase/src/platform/types.ts +++ b/packages/mcp-server-supabase/src/platform/types.ts @@ -148,6 +148,12 @@ export const getLogsOptionsSchema = z.object({ iso_timestamp_end: z.string().optional(), }); +export const queryLogsOptionsSchema = z.object({ + sql: z.string().min(1), + iso_timestamp_start: z.string().optional(), + iso_timestamp_end: z.string().optional(), +}); + export const generateTypescriptTypesResultSchema = z.object({ types: z.string(), }); @@ -172,6 +178,7 @@ export type ListMigrationsResult = z.infer; export type LogsService = z.infer; export type GetLogsOptions = z.infer; +export type QueryLogsOptions = z.infer; export type GenerateTypescriptTypesResult = z.infer< typeof generateTypescriptTypesResultSchema >; @@ -212,6 +219,7 @@ export type EdgeFunctionsOperations = { export type DebuggingOperations = { getLogs(projectId: string, options: GetLogsOptions): Promise; + queryLogs(projectId: string, options: QueryLogsOptions): Promise; getSecurityAdvisors(projectId: string): Promise; getPerformanceAdvisors(projectId: string): Promise; }; diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index d08564d..026ee5b 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -1617,6 +1617,182 @@ describe('tools', () => { ); }); + test('query logs forwards custom sql and defaults the timestamp window', async () => { + const { callTool } = await setup(); + + 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 capturedSearchParams: URLSearchParams[] = []; + + mockServer?.use( + http.get<{ projectId: string }>( + `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, + ({ params, request }) => { + expect(params.projectId).toBe(project.id); + capturedSearchParams.push(new URL(request.url).searchParams); + + return HttpResponse.json([]); + } + ) + ); + + const sql = + "select id, timestamp, event_message from logs where source = 'postgres_logs' order by timestamp desc limit 10"; + + const { result } = await callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql, + }, + }); + + expect(result).toContain('untrusted-data'); + expect(capturedSearchParams).toHaveLength(1); + expect(capturedSearchParams[0]?.get('sql')).toBe(sql); + expect(capturedSearchParams[0]?.get('iso_timestamp_start')).toBeTruthy(); + expect(capturedSearchParams[0]?.get('iso_timestamp_end')).toBeTruthy(); + }); + + test('query logs forwards a custom timestamp window', async () => { + const { callTool } = await setup(); + + 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 capturedSearchParams: URLSearchParams[] = []; + + mockServer?.use( + http.get<{ projectId: string }>( + `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, + ({ request }) => { + capturedSearchParams.push(new URL(request.url).searchParams); + return HttpResponse.json([]); + } + ) + ); + + const isoTimestampStart = '2024-02-01T10:00:00.000Z'; + const isoTimestampEnd = '2024-02-01T11:00:00.000Z'; + + await callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql: 'select id from logs limit 1', + iso_timestamp_start: isoTimestampStart, + iso_timestamp_end: isoTimestampEnd, + }, + }); + + expect(capturedSearchParams).toHaveLength(1); + expect(capturedSearchParams[0]?.get('iso_timestamp_start')).toBe( + isoTimestampStart + ); + expect(capturedSearchParams[0]?.get('iso_timestamp_end')).toBe( + isoTimestampEnd + ); + }); + + test('query logs anchors the default start to a supplied end', async () => { + const { callTool } = await setup(); + + 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 capturedSearchParams: URLSearchParams[] = []; + + mockServer?.use( + http.get<{ projectId: string }>( + `${API_URL}/v1/projects/:projectId/analytics/endpoints/logs`, + ({ request }) => { + capturedSearchParams.push(new URL(request.url).searchParams); + return HttpResponse.json([]); + } + ) + ); + + const isoTimestampEnd = '2024-02-01T11:00:00.000Z'; + + await callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql: 'select id from logs limit 1', + iso_timestamp_end: isoTimestampEnd, + }, + }); + + expect(capturedSearchParams).toHaveLength(1); + expect(capturedSearchParams[0]?.get('iso_timestamp_end')).toBe( + isoTimestampEnd + ); + const expectedStart = new Date( + new Date(isoTimestampEnd).getTime() - 24 * 60 * 60 * 1000 + ).toISOString(); + expect(capturedSearchParams[0]?.get('iso_timestamp_start')).toBe( + expectedStart + ); + }); + + test('query logs rejects an empty sql query', async () => { + const { callTool } = await setup(); + + 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'; + + await expect( + callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql: '', + }, + }) + ).rejects.toThrow(); + }); + test('get security advisors', async () => { const { callTool } = await setup(); @@ -3267,7 +3443,7 @@ describe('feature groups', () => { const { tools } = await client.listTools(); const toolNames = tools.map((tool) => tool.name); - expect(toolNames).toEqual(['get_logs', 'get_advisors']); + expect(toolNames).toEqual(['get_logs', 'query_logs', 'get_advisors']); }); test('development tools', async () => { diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index b620bc3..8db4e98 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -21,13 +21,13 @@ const getLogsInputSchema = z.object({ .string() .optional() .describe( - 'The start of the log window as an ISO 8601 timestamp. The API caps the requested range at 24 hours.' + 'The start of the log window as an ISO 8601 timestamp. Defaults to 24 hours before the end of the window. The API caps the requested range at 24 hours.' ), iso_timestamp_end: z .string() .optional() .describe( - 'The end of the log window as an ISO 8601 timestamp. The API caps the requested range at 24 hours.' + 'The end of the log window as an ISO 8601 timestamp. Defaults to the current time. The API caps the requested range at 24 hours.' ), }); @@ -35,6 +35,32 @@ const getLogsOutputSchema = z.object({ result: z.unknown(), }); +const queryLogsInputSchema = z.object({ + project_id: z.string(), + sql: z + .string() + .min(1) + .describe( + "A read-only ClickHouse SQL query to run against the project's unified logs stream. Logs are exposed through a `logs` table; filter by `source` (e.g. 'edge_logs', 'postgres_logs', 'function_edge_logs', 'function_logs', 'auth_logs', 'storage_logs', 'realtime_logs', 'workflow_run_logs') and read nested fields via `log_attributes['']`." + ), + iso_timestamp_start: z + .string() + .optional() + .describe( + 'The start of the log window as an ISO 8601 timestamp. Defaults to 24 hours before the end of the window. The API caps the requested range at 24 hours.' + ), + iso_timestamp_end: z + .string() + .optional() + .describe( + 'The end of the log window as an ISO 8601 timestamp. Defaults to the current time. The API caps the requested range at 24 hours.' + ), +}); + +const queryLogsOutputSchema = z.object({ + result: z.unknown(), +}); + const getAdvisorsInputSchema = z.object({ project_id: z.string(), type: z @@ -49,7 +75,7 @@ const getAdvisorsOutputSchema = z.object({ export const debuggingToolDefs = { get_logs: { description: - 'Gets logs for a Supabase project by service type. Each call returns logs from the last 24 hours by default. Provide a custom iso_timestamp_start/iso_timestamp_end window up to 24 hours. Edge Function logs are split by kind: `edge-function` returns invocation/request logs, while `edge-function-runtime` returns console output from inside the function. Query one service first, then correlate with other services by timestamp or error anchors. Do not poll get_logs in a loop.', + 'Gets logs for a Supabase project by service type. When the user asks about a specific time range, always pass iso_timestamp_start and iso_timestamp_end to match it; otherwise each call defaults to the last 24 hours and will return logs from a wider window than intended. The window can be up to 24 hours. Edge Function logs are split by kind: `edge-function` returns invocation/request logs, while `edge-function-runtime` returns console output from inside the function. Query one service first, then correlate with other services by timestamp or error anchors. Do not poll get_logs in a loop. Deprecated on hosted (production) projects in favour of `query_logs`, which supports custom ClickHouse queries; prefer `query_logs` there. Continue using `get_logs` on local (CLI) and self-hosted projects until ClickHouse-backed querying is available for them.', parameters: getLogsInputSchema, outputSchema: getLogsOutputSchema, annotations: { @@ -60,6 +86,19 @@ export const debuggingToolDefs = { openWorldHint: false, }, }, + query_logs: { + description: + "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream, for filtering, aggregating, or joining across log fields more precisely than the `get_logs` service presets allow. Only works on hosted (production) Supabase projects: on hosted projects, prefer this over `get_logs` whenever you need more than a simple per-service log dump. On local (CLI) and self-hosted projects this query will fail because ClickHouse-backed querying is not yet supported there, so use `get_logs` instead even when its presets are coarser — it is the only logs tool that works on those projects. When the user asks about a specific time range, always pass iso_timestamp_start and iso_timestamp_end to match it; otherwise the query defaults to the last 24 hours and will return results from a wider window than intended. The window can be up to 24 hours. Do not poll this tool in a loop.", + parameters: queryLogsInputSchema, + outputSchema: queryLogsOutputSchema, + annotations: { + title: 'Query project logs', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, get_advisors: { description: "Gets a list of advisory notices for the Supabase project. Use this to check for security vulnerabilities or performance improvements. Include the remediation URL as a clickable link so that the user can reference the issue themselves. It's recommended to run this tool regularly, especially after making DDL changes to the database since it will catch things like missing RLS policies.", @@ -75,6 +114,19 @@ export const debuggingToolDefs = { }, } as const satisfies ToolDefs; +const DAY_MS = 24 * 60 * 60 * 1000; + +function resolveLogWindow( + iso_timestamp_start?: string, + iso_timestamp_end?: string +) { + const end = iso_timestamp_end ?? new Date().toISOString(); + const start = + iso_timestamp_start ?? + new Date(new Date(end).getTime() - DAY_MS).toISOString(); + return { iso_timestamp_start: start, iso_timestamp_end: end }; +} + export function getDebuggingTools({ debugging, projectId, @@ -91,16 +143,25 @@ export function getDebuggingTools({ iso_timestamp_start, iso_timestamp_end, }) => { - const endTimestamp = new Date(); - const startTimestamp = new Date( - endTimestamp.getTime() - 24 * 60 * 60 * 1000 - ); // Last 24 hours - const result = await debugging.getLogs(project_id, { service, - iso_timestamp_start: - iso_timestamp_start ?? startTimestamp.toISOString(), - iso_timestamp_end: iso_timestamp_end ?? endTimestamp.toISOString(), + ...resolveLogWindow(iso_timestamp_start, iso_timestamp_end), + }); + return { result: wrapWithUntrustedDataBoundary(result) }; + }, + }), + query_logs: injectableTool({ + ...debuggingToolDefs.query_logs, + inject: { project_id }, + execute: async ({ + project_id, + sql, + iso_timestamp_start, + iso_timestamp_end, + }) => { + const result = await debugging.queryLogs(project_id, { + sql, + ...resolveLogWindow(iso_timestamp_start, iso_timestamp_end), }); return { result: wrapWithUntrustedDataBoundary(result) }; },