diff --git a/packages/mcp-server-supabase/src/management-api/types.ts b/packages/mcp-server-supabase/src/management-api/types.ts index 2db0b0b1..ab50fc5c 100644 --- a/packages/mcp-server-supabase/src/management-api/types.ts +++ b/packages/mcp-server-supabase/src/management-api/types.ts @@ -2458,7 +2458,7 @@ export interface components { /** @enum {string} */ type: "specific"; /** @enum {string} */ - provider: "AWS" | "FLY" | "AWS_K8S" | "AWS_NIMBUS"; + provider: "AWS" | "AWS_K8S" | "AWS_NIMBUS"; /** @enum {string} */ status?: "capacity" | "other"; }[]; @@ -2478,7 +2478,7 @@ export interface components { /** @enum {string} */ type: "specific"; /** @enum {string} */ - provider: "AWS" | "FLY" | "AWS_K8S" | "AWS_NIMBUS"; + provider: "AWS" | "AWS_K8S" | "AWS_NIMBUS"; /** @enum {string} */ status?: "capacity" | "other"; }[]; diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index d97bb34f..5686e0c1 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 0021a9af..c568fcd6 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 cc208ae9..8c996b02 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -2072,6 +2072,259 @@ 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 before = Date.now(); + const { result } = await callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql, + }, + }); + const after = Date.now(); + + expect(result).toContain('untrusted-data'); + expect(capturedSearchParams).toHaveLength(1); + expect(capturedSearchParams[0]?.get('sql')).toBe(sql); + + const end = capturedSearchParams[0]?.get('iso_timestamp_end'); + const start = capturedSearchParams[0]?.get('iso_timestamp_start'); + const endMs = Date.parse(end!); + + expect(endMs).toBeGreaterThanOrEqual(before); + expect(endMs).toBeLessThanOrEqual(after); + expect(start).toBe(new Date(endMs - 24 * 60 * 60 * 1000).toISOString()); + }); + + 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 a malformed iso_timestamp_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'; + + await expect( + callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql: 'select id from logs limit 1', + iso_timestamp_end: 'not-a-timestamp', + }, + }) + ).rejects.toThrow(/Invalid ISO datetime/); + }); + + test('query logs rejects a start at or after the 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'; + + await expect( + callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql: 'select id from logs limit 1', + iso_timestamp_start: '2024-02-01T11:00:00.000Z', + iso_timestamp_end: '2024-02-01T10:00:00.000Z', + }, + }) + ).rejects.toThrow(/must be before/); + + await expect( + callTool({ + name: 'query_logs', + arguments: { + project_id: project.id, + sql: 'select id from logs limit 1', + iso_timestamp_start: '2024-02-01T10:00:00.000Z', + iso_timestamp_end: '2024-02-01T10:00:00.000Z', + }, + }) + ).rejects.toThrow(/must be before/); + }); + + 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(/too_small|at least 1 character/); + }); + test('get security advisors', async () => { const { callTool } = await setup(); @@ -3741,6 +3994,28 @@ describe('feature groups', () => { const { tools } = await client.listTools(); const toolNames = tools.map((tool) => tool.name); + expect(toolNames).toEqual(['get_logs', 'query_logs', 'get_advisors']); + }); + + test('debugging tools omit query_logs when the platform does not implement it', async () => { + const platform: SupabasePlatform = { + debugging: { + getLogs() { + throw new Error('Not implemented'); + }, + getSecurityAdvisors() { + throw new Error('Not implemented'); + }, + getPerformanceAdvisors() { + throw new Error('Not implemented'); + }, + }, + }; + + const { client } = await setup({ platform, features: ['debugging'] }); + const { tools } = await client.listTools(); + const toolNames = tools.map((tool) => tool.name); + expect(toolNames).toEqual(['get_logs', 'get_advisors']); }); diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.test.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.test.ts new file mode 100644 index 00000000..b8c8649b --- /dev/null +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest'; +import { DAY_MS, resolveLogWindow } from './debugging-tools.js'; + +describe('resolveLogWindow', () => { + test('defaults the end to now and the start to 24 hours before it', () => { + const before = Date.now(); + const { iso_timestamp_start, iso_timestamp_end } = resolveLogWindow(); + const after = Date.now(); + + const endMs = Date.parse(iso_timestamp_end); + const startMs = Date.parse(iso_timestamp_start); + + expect(endMs).toBeGreaterThanOrEqual(before); + expect(endMs).toBeLessThanOrEqual(after); + expect(startMs).toBe(endMs - DAY_MS); + }); + + test('anchors the default start to a supplied end', () => { + const end = '2024-02-01T11:00:00.000Z'; + const { iso_timestamp_start, iso_timestamp_end } = resolveLogWindow( + undefined, + end + ); + + expect(iso_timestamp_end).toBe(end); + expect(iso_timestamp_start).toBe( + new Date(Date.parse(end) - DAY_MS).toISOString() + ); + }); + + test('normalizes accepted timestamps to canonical UTC ISO strings', () => { + const { iso_timestamp_start, iso_timestamp_end } = resolveLogWindow( + '2024-02-01T09:00:00.000+01:00', + '2024-02-01T11:00:00.000+01:00' + ); + + expect(iso_timestamp_start).toBe('2024-02-01T08:00:00.000Z'); + expect(iso_timestamp_end).toBe('2024-02-01T10:00:00.000Z'); + }); + + test('rejects a malformed iso_timestamp_end', () => { + expect(() => resolveLogWindow(undefined, 'not-a-timestamp')).toThrow( + /Invalid iso_timestamp_end/ + ); + }); + + test('rejects a malformed iso_timestamp_start', () => { + expect(() => + resolveLogWindow('not-a-timestamp', '2024-02-01T11:00:00.000Z') + ).toThrow(/Invalid iso_timestamp_start/); + }); + + test('rejects a start at or after the end', () => { + expect(() => + resolveLogWindow('2024-02-01T11:00:00.000Z', '2024-02-01T10:00:00.000Z') + ).toThrow(/must be before/); + }); + + test('rejects a window longer than 24 hours', () => { + expect(() => + resolveLogWindow('2024-02-01T00:00:00.000Z', '2024-02-02T00:00:00.001Z') + ).toThrow(/at most 24 hours/); + }); + + test('accepts a window exactly 24 hours long', () => { + const start = '2024-02-01T00:00:00.000Z'; + const end = '2024-02-02T00:00:00.000Z'; + + expect(() => resolveLogWindow(start, end)).not.toThrow(); + }); +}); diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index b620bc34..7d39728f 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -17,17 +17,17 @@ type DebuggingToolsOptions = { const getLogsInputSchema = z.object({ project_id: z.string(), service: logsServiceSchema.describe('The service to fetch logs for'), - iso_timestamp_start: z - .string() + iso_timestamp_start: z.iso + .datetime({ offset: true }) .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, including a UTC "Z" suffix or explicit offset. Defaults to 24 hours before the end of the window. The API caps the requested range at 24 hours.' ), - iso_timestamp_end: z - .string() + iso_timestamp_end: z.iso + .datetime({ offset: true }) .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, including a UTC "Z" suffix or explicit offset. 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.iso + .datetime({ offset: true }) + .optional() + .describe( + 'The start of the log window as an ISO 8601 timestamp, including a UTC "Z" suffix or explicit offset. Defaults to 24 hours before the end of the window. The API caps the requested range at 24 hours.' + ), + iso_timestamp_end: z.iso + .datetime({ offset: true }) + .optional() + .describe( + 'The end of the log window as an ISO 8601 timestamp, including a UTC "Z" suffix or explicit offset. 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. On hosted (production) projects, prefer `query_logs` instead whenever you need more than a simple per-service log dump, since it supports custom ClickHouse queries. On local (CLI) and self-hosted projects, use `get_logs`: it is the only logs tool available there, since ClickHouse-backed querying is not yet supported on those platforms.', 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,11 +114,48 @@ export const debuggingToolDefs = { }, } as const satisfies ToolDefs; +export const DAY_MS = 24 * 60 * 60 * 1000; + +export function resolveLogWindow( + iso_timestamp_start?: string, + iso_timestamp_end?: string +) { + const endMs = iso_timestamp_end ? Date.parse(iso_timestamp_end) : Date.now(); + if (Number.isNaN(endMs)) { + throw new Error( + `Invalid iso_timestamp_end: "${iso_timestamp_end}". Expected an ISO 8601 timestamp.` + ); + } + + const startMs = iso_timestamp_start + ? Date.parse(iso_timestamp_start) + : endMs - DAY_MS; + if (Number.isNaN(startMs)) { + throw new Error( + `Invalid iso_timestamp_start: "${iso_timestamp_start}". Expected an ISO 8601 timestamp.` + ); + } + + if (startMs >= endMs) { + throw new Error('iso_timestamp_start must be before iso_timestamp_end.'); + } + + if (endMs - startMs > DAY_MS) { + throw new Error('The log window can be at most 24 hours.'); + } + + return { + iso_timestamp_start: new Date(startMs).toISOString(), + iso_timestamp_end: new Date(endMs).toISOString(), + }; +} + export function getDebuggingTools({ debugging, projectId, }: DebuggingToolsOptions) { const project_id = projectId; + const { queryLogs } = debugging; return { get_logs: injectableTool({ @@ -91,20 +167,31 @@ 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) }; }, }), + ...(queryLogs && { + query_logs: injectableTool({ + ...debuggingToolDefs.query_logs, + inject: { project_id }, + execute: async ({ + project_id, + sql, + iso_timestamp_start, + iso_timestamp_end, + }) => { + const result = await queryLogs(project_id, { + sql, + ...resolveLogWindow(iso_timestamp_start, iso_timestamp_end), + }); + return { result: wrapWithUntrustedDataBoundary(result) }; + }, + }), + }), get_advisors: injectableTool({ ...debuggingToolDefs.get_advisors, inject: { project_id },