From c7b17e44c34d158fa6f1da4829fc46c2760c624c Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 16 Jul 2026 16:24:10 +0200 Subject: [PATCH 01/18] feat: add query_logs tool for custom log queries Adds a query_logs debugging tool that runs a custom read-only ClickHouse SQL query against a project's unified logs stream, for cases where the get_logs service presets are too coarse. Reuses the existing analytics logs endpoint and validates that queries are SELECT/WITH only. --- packages/mcp-server-supabase/src/logs.test.ts | 33 +++++++++- packages/mcp-server-supabase/src/logs.ts | 18 ++++++ .../src/platform/api-platform.ts | 30 ++++++++- .../mcp-server-supabase/src/platform/types.ts | 8 +++ .../src/tools/debugging-tools.ts | 61 +++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server-supabase/src/logs.test.ts b/packages/mcp-server-supabase/src/logs.test.ts index 9855e20a..3daad947 100644 --- a/packages/mcp-server-supabase/src/logs.test.ts +++ b/packages/mcp-server-supabase/src/logs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { getClickHouseLogQuery } from './logs.js'; +import { assertReadOnlyLogQuery, getClickHouseLogQuery } from './logs.js'; import type { LogsService } from './platform/types.js'; const serviceSources = { @@ -38,3 +38,34 @@ describe('getClickHouseLogQuery', () => { expect(query).not.toContain("log_attributes['response.status_code']"); }); }); + +describe('assertReadOnlyLogQuery', () => { + test('allows SELECT and WITH queries, ignoring comments and casing', () => { + expect(() => + assertReadOnlyLogQuery('select id from logs limit 10') + ).not.toThrow(); + expect(() => + assertReadOnlyLogQuery(' WITH x as (select 1) select * from x ') + ).not.toThrow(); + expect(() => + assertReadOnlyLogQuery('-- comment\nselect id from logs;') + ).not.toThrow(); + }); + + test('rejects non-read statements', () => { + for (const sql of [ + "insert into logs values ('x')", + 'delete from logs', + 'drop table logs', + '/* select */ update logs set id = 1', + ]) { + expect(() => assertReadOnlyLogQuery(sql)).toThrow(/read-only/); + } + }); + + test('rejects multiple statements', () => { + expect(() => + assertReadOnlyLogQuery('select 1; drop table logs') + ).toThrow(/single/); + }); +}); diff --git a/packages/mcp-server-supabase/src/logs.ts b/packages/mcp-server-supabase/src/logs.ts index 8d449e6a..25c576a2 100644 --- a/packages/mcp-server-supabase/src/logs.ts +++ b/packages/mcp-server-supabase/src/logs.ts @@ -1,6 +1,24 @@ import { stripIndent } from 'common-tags'; import type { LogsService } from './platform/types.js'; +export function assertReadOnlyLogQuery(sql: string) { + const stripped = sql + .replace(/--[^\n]*/g, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .trim() + .replace(/;\s*$/, ''); + + if (stripped.includes(';')) { + throw new Error('Only a single log query statement is allowed.'); + } + + if (!/^(select|with)\b/i.test(stripped)) { + throw new Error( + 'Only read-only log queries are allowed. The query must start with SELECT or WITH.' + ); + } +} + export function getClickHouseLogQuery( service: LogsService, limit: number = 100 diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index d97bb34f..c0af74e7 100644 --- a/packages/mcp-server-supabase/src/platform/api-platform.ts +++ b/packages/mcp-server-supabase/src/platform/api-platform.ts @@ -6,7 +6,7 @@ import type { InitData } from '@supabase/mcp-utils'; import { fileURLToPath } from 'node:url'; import packageJson from '../../package.json' with { type: 'json' }; import { getDeploymentId, normalizeFilename } from '../edge-function.js'; -import { getClickHouseLogQuery } from '../logs.js'; +import { assertReadOnlyLogQuery, getClickHouseLogQuery } from '../logs.js'; import { assertProjectScopedSuccess, assertSuccess, @@ -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,32 @@ export function createSupabaseApiPlatform( return response.data; }, + async queryLogs(projectId: string, options: QueryLogsOptions) { + const { sql, iso_timestamp_start, iso_timestamp_end } = + queryLogsOptionsSchema.parse(options); + + assertReadOnlyLogQuery(sql); + + 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..49142ed3 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(), + 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/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index b620bc34..0f9cf358 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -35,6 +35,31 @@ const getLogsOutputSchema = z.object({ result: z.unknown(), }); +const queryLogsInputSchema = z.object({ + project_id: z.string(), + sql: z + .string() + .describe( + "A read-only ClickHouse SQL query to run against the project's unified logs stream. Must start with SELECT or WITH. Logs are exposed through a `logs` table; filter by `source` (e.g. 'edge_logs', 'postgres_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. 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.' + ), +}); + +const queryLogsOutputSchema = z.object({ + result: z.unknown(), +}); + const getAdvisorsInputSchema = z.object({ project_id: z.string(), type: z @@ -60,6 +85,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. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. Queries the last 24 hours by default; provide a custom iso_timestamp_start/iso_timestamp_end window up to 24 hours. Only SELECT/WITH queries are allowed. 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.", @@ -105,6 +143,29 @@ export function getDebuggingTools({ 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 endTimestamp = new Date(); + const startTimestamp = new Date( + endTimestamp.getTime() - 24 * 60 * 60 * 1000 + ); // Last 24 hours + + const result = await debugging.queryLogs(project_id, { + sql, + iso_timestamp_start: + iso_timestamp_start ?? startTimestamp.toISOString(), + iso_timestamp_end: iso_timestamp_end ?? endTimestamp.toISOString(), + }); + return { result: wrapWithUntrustedDataBoundary(result) }; + }, + }), get_advisors: injectableTool({ ...debuggingToolDefs.get_advisors, inject: { project_id }, From e45c78c6725813edcf3f8ecdcae20971439756b8 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 16 Jul 2026 16:26:06 +0200 Subject: [PATCH 02/18] chore: format logs.test.ts with biome --- packages/mcp-server-supabase/src/logs.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server-supabase/src/logs.test.ts b/packages/mcp-server-supabase/src/logs.test.ts index 3daad947..4aab6757 100644 --- a/packages/mcp-server-supabase/src/logs.test.ts +++ b/packages/mcp-server-supabase/src/logs.test.ts @@ -64,8 +64,8 @@ describe('assertReadOnlyLogQuery', () => { }); test('rejects multiple statements', () => { - expect(() => - assertReadOnlyLogQuery('select 1; drop table logs') - ).toThrow(/single/); + expect(() => assertReadOnlyLogQuery('select 1; drop table logs')).toThrow( + /single/ + ); }); }); From 776a69490ec3d625a2af1a5f557d2eeb61983e3c Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 16 Jul 2026 16:28:12 +0200 Subject: [PATCH 03/18] test: include query_logs in debugging feature group assertion --- packages/mcp-server-supabase/src/server.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index cc208ae9..1ef6fbfe 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -3741,7 +3741,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 () => { From 67f51d239d3b7b5d739502e5cf8eb728c2932159 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 16 Jul 2026 16:31:50 +0200 Subject: [PATCH 04/18] refactor: drop SELECT-only guard, backend enforces read-only --- packages/mcp-server-supabase/src/logs.test.ts | 33 +------------------ packages/mcp-server-supabase/src/logs.ts | 18 ---------- .../src/platform/api-platform.ts | 4 +-- .../src/tools/debugging-tools.ts | 4 +-- 4 files changed, 4 insertions(+), 55 deletions(-) diff --git a/packages/mcp-server-supabase/src/logs.test.ts b/packages/mcp-server-supabase/src/logs.test.ts index 4aab6757..9855e20a 100644 --- a/packages/mcp-server-supabase/src/logs.test.ts +++ b/packages/mcp-server-supabase/src/logs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { assertReadOnlyLogQuery, getClickHouseLogQuery } from './logs.js'; +import { getClickHouseLogQuery } from './logs.js'; import type { LogsService } from './platform/types.js'; const serviceSources = { @@ -38,34 +38,3 @@ describe('getClickHouseLogQuery', () => { expect(query).not.toContain("log_attributes['response.status_code']"); }); }); - -describe('assertReadOnlyLogQuery', () => { - test('allows SELECT and WITH queries, ignoring comments and casing', () => { - expect(() => - assertReadOnlyLogQuery('select id from logs limit 10') - ).not.toThrow(); - expect(() => - assertReadOnlyLogQuery(' WITH x as (select 1) select * from x ') - ).not.toThrow(); - expect(() => - assertReadOnlyLogQuery('-- comment\nselect id from logs;') - ).not.toThrow(); - }); - - test('rejects non-read statements', () => { - for (const sql of [ - "insert into logs values ('x')", - 'delete from logs', - 'drop table logs', - '/* select */ update logs set id = 1', - ]) { - expect(() => assertReadOnlyLogQuery(sql)).toThrow(/read-only/); - } - }); - - test('rejects multiple statements', () => { - expect(() => assertReadOnlyLogQuery('select 1; drop table logs')).toThrow( - /single/ - ); - }); -}); diff --git a/packages/mcp-server-supabase/src/logs.ts b/packages/mcp-server-supabase/src/logs.ts index 25c576a2..8d449e6a 100644 --- a/packages/mcp-server-supabase/src/logs.ts +++ b/packages/mcp-server-supabase/src/logs.ts @@ -1,24 +1,6 @@ import { stripIndent } from 'common-tags'; import type { LogsService } from './platform/types.js'; -export function assertReadOnlyLogQuery(sql: string) { - const stripped = sql - .replace(/--[^\n]*/g, '') - .replace(/\/\*[\s\S]*?\*\//g, '') - .trim() - .replace(/;\s*$/, ''); - - if (stripped.includes(';')) { - throw new Error('Only a single log query statement is allowed.'); - } - - if (!/^(select|with)\b/i.test(stripped)) { - throw new Error( - 'Only read-only log queries are allowed. The query must start with SELECT or WITH.' - ); - } -} - export function getClickHouseLogQuery( service: LogsService, limit: number = 100 diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index c0af74e7..5686e0c1 100644 --- a/packages/mcp-server-supabase/src/platform/api-platform.ts +++ b/packages/mcp-server-supabase/src/platform/api-platform.ts @@ -6,7 +6,7 @@ import type { InitData } from '@supabase/mcp-utils'; import { fileURLToPath } from 'node:url'; import packageJson from '../../package.json' with { type: 'json' }; import { getDeploymentId, normalizeFilename } from '../edge-function.js'; -import { assertReadOnlyLogQuery, getClickHouseLogQuery } from '../logs.js'; +import { getClickHouseLogQuery } from '../logs.js'; import { assertProjectScopedSuccess, assertSuccess, @@ -283,8 +283,6 @@ export function createSupabaseApiPlatform( const { sql, iso_timestamp_start, iso_timestamp_end } = queryLogsOptionsSchema.parse(options); - assertReadOnlyLogQuery(sql); - const response = await managementApiClient.GET( '/v1/projects/{ref}/analytics/endpoints/logs', { diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 0f9cf358..9b4e2ae6 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -40,7 +40,7 @@ const queryLogsInputSchema = z.object({ sql: z .string() .describe( - "A read-only ClickHouse SQL query to run against the project's unified logs stream. Must start with SELECT or WITH. Logs are exposed through a `logs` table; filter by `source` (e.g. 'edge_logs', 'postgres_logs', 'function_logs', 'auth_logs', 'storage_logs', 'realtime_logs', 'workflow_run_logs') and read nested fields via `log_attributes['']`." + "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_logs', 'auth_logs', 'storage_logs', 'realtime_logs', 'workflow_run_logs') and read nested fields via `log_attributes['']`." ), iso_timestamp_start: z .string() @@ -87,7 +87,7 @@ export const debuggingToolDefs = { }, query_logs: { description: - "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. Queries the last 24 hours by default; provide a custom iso_timestamp_start/iso_timestamp_end window up to 24 hours. Only SELECT/WITH queries are allowed. Do not poll this tool in a loop.", + "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. Queries the last 24 hours by default; provide a custom iso_timestamp_start/iso_timestamp_end window up to 24 hours. Do not poll this tool in a loop.", parameters: queryLogsInputSchema, outputSchema: queryLogsOutputSchema, annotations: { From 3fa70e29bc9d0be251cc83bd2603e76fd837de7b Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Fri, 17 Jul 2026 11:26:06 +0200 Subject: [PATCH 05/18] feat: address query_logs review feedback - add function_edge_logs to the sql source-hint list so models can reach edge function invocation logs - require a non-empty sql query (.min(1)), matching execute_sql - add execution tests for query_logs: sql passthrough + timestamp defaulting, custom window forwarding, and empty-query rejection --- .../mcp-server-supabase/src/platform/types.ts | 2 +- .../mcp-server-supabase/src/server.test.ts | 125 ++++++++++++++++++ .../src/tools/debugging-tools.ts | 3 +- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server-supabase/src/platform/types.ts b/packages/mcp-server-supabase/src/platform/types.ts index 49142ed3..276e68a5 100644 --- a/packages/mcp-server-supabase/src/platform/types.ts +++ b/packages/mcp-server-supabase/src/platform/types.ts @@ -149,7 +149,7 @@ export const getLogsOptionsSchema = z.object({ }); export const queryLogsOptionsSchema = z.object({ - sql: z.string(), + sql: z.string().min(1), iso_timestamp_start: z.string().optional(), iso_timestamp_end: z.string().optional(), }); diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 1ef6fbfe..244b2052 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -2072,6 +2072,131 @@ 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 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(); diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 9b4e2ae6..c9f9194b 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -39,8 +39,9 @@ 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_logs', 'auth_logs', 'storage_logs', 'realtime_logs', 'workflow_run_logs') and read nested fields via `log_attributes['']`." + "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() From 86acca137151cdc74f49cf43fe6774b80f81ae5c Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 21 Jul 2026 11:15:38 +0200 Subject: [PATCH 06/18] docs: deprecate get_logs for query_logs, note ClickHouse availability - mark get_logs as deprecated on hosted projects in favour of query_logs, while keeping it as the path for CLI/self-hosted - document that query_logs (ClickHouse) is hosted-only and will not work on CLI/self-hosted yet --- packages/mcp-server-supabase/src/tools/debugging-tools.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index c9f9194b..766246bc 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -75,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. 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. 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: { @@ -88,7 +88,7 @@ export const debuggingToolDefs = { }, query_logs: { description: - "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. Queries the last 24 hours by default; provide a custom iso_timestamp_start/iso_timestamp_end window up to 24 hours. Do not poll this tool in a loop.", + "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. Queries the last 24 hours by default; provide a custom iso_timestamp_start/iso_timestamp_end window up to 24 hours. Do not poll this tool in a loop. Only available on hosted (production) Supabase projects; ClickHouse-backed querying is not yet available for local (CLI) or self-hosted projects, where you should use `get_logs` instead.", parameters: queryLogsInputSchema, outputSchema: queryLogsOutputSchema, annotations: { From f2755275d54287313788d81d9291d4c48fa86b50 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 21 Jul 2026 12:02:52 +0200 Subject: [PATCH 07/18] fix: make time-range guidance directive in log tool descriptions The permissive mention of iso_timestamp_start/iso_timestamp_end wasn't steering model behaviour, so narrow time-range questions silently inherited the 24h default and over-counted. Make it a directive instruction in both get_logs and query_logs, matching the mechanism that flipped tool selection. --- packages/mcp-server-supabase/src/tools/debugging-tools.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 766246bc..230df35d 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -75,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. 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.', + '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: { @@ -88,7 +88,7 @@ export const debuggingToolDefs = { }, query_logs: { description: - "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. Queries the last 24 hours by default; provide a custom iso_timestamp_start/iso_timestamp_end window up to 24 hours. Do not poll this tool in a loop. Only available on hosted (production) Supabase projects; ClickHouse-backed querying is not yet available for local (CLI) or self-hosted projects, where you should use `get_logs` instead.", + "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. 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. Only available on hosted (production) Supabase projects; ClickHouse-backed querying is not yet available for local (CLI) or self-hosted projects, where you should use `get_logs` instead.", parameters: queryLogsInputSchema, outputSchema: queryLogsOutputSchema, annotations: { From 8d9ddcf3a6d1fa6a45e1835fbf0dcb5ff1d8ae6c Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 21 Jul 2026 12:21:59 +0200 Subject: [PATCH 08/18] docs: state timestamp defaults in log tool descriptions --- packages/mcp-server-supabase/src/tools/debugging-tools.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 230df35d..1397b732 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.' ), }); @@ -47,13 +47,13 @@ const queryLogsInputSchema = 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.' ), }); From fe3300234cd8b6c76e36a67aabd6eb4fc48c2eef Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 21 Jul 2026 12:56:22 +0200 Subject: [PATCH 09/18] docs: clarify query_logs vs get_logs availability in description --- packages/mcp-server-supabase/src/tools/debugging-tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 1397b732..f7ee1b43 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -88,7 +88,7 @@ export const debuggingToolDefs = { }, query_logs: { description: - "Runs a custom read-only ClickHouse SQL query against a Supabase project's unified logs stream. Use this when `get_logs` (service presets) is too coarse and you need to filter, aggregate, or join across log fields. 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. Only available on hosted (production) Supabase projects; ClickHouse-backed querying is not yet available for local (CLI) or self-hosted projects, where you should use `get_logs` instead.", + "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: { From 81289679ebce0a7c1575932ec79d57e3dd1d1272 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 21 Jul 2026 15:44:06 +0200 Subject: [PATCH 10/18] fix: anchor default log window start to the supplied end The description promises iso_timestamp_start defaults to 24h before the end, but the handler always computed start from now(), so supplying only iso_timestamp_end produced an inverted/empty window. Derive the end first (supplied or now), then default start to end - 24h, shared by get_logs and query_logs. --- .../mcp-server-supabase/src/server.test.ts | 51 +++++++++++++++++++ .../src/tools/debugging-tools.ts | 31 ++++++----- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 244b2052..b34e2675 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -2170,6 +2170,57 @@ describe('tools', () => { ); }); + 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(); diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index f7ee1b43..8db4e980 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -114,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, @@ -130,16 +143,9 @@ 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) }; }, @@ -153,16 +159,9 @@ 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.queryLogs(project_id, { sql, - 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) }; }, From 09e521ee244cb0d38bb8e2e5d8ba9858c532ed7f Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Mon, 3 Aug 2026 16:51:44 +0200 Subject: [PATCH 11/18] fix: validate the log query window, reject empty/inverted ranges resolveLogWindow now rejects a malformed iso_timestamp_start/end with a clear error instead of throwing a raw "Invalid time value", and rejects a start at or after the end. Also rebases onto main to pick up the regenerated management API types. --- .../mcp-server-supabase/src/server.test.ts | 57 +++++++++++++++++++ .../src/tools/debugging-tools.ts | 22 ++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index b34e2675..f52c6b84 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -2221,6 +2221,63 @@ describe('tools', () => { ); }); + 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(); + }); + + 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(); + }); + test('query logs rejects an empty sql query', async () => { const { callTool } = await setup(); diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 8db4e980..b45d7ec7 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -121,9 +121,25 @@ function resolveLogWindow( 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(); + const endMs = Date.parse(end); + if (Number.isNaN(endMs)) { + throw new Error( + `Invalid iso_timestamp_end: "${end}". Expected an ISO 8601 timestamp.` + ); + } + + const start = iso_timestamp_start ?? new Date(endMs - DAY_MS).toISOString(); + const startMs = Date.parse(start); + if (Number.isNaN(startMs)) { + throw new Error( + `Invalid iso_timestamp_start: "${start}". Expected an ISO 8601 timestamp.` + ); + } + + if (startMs >= endMs) { + throw new Error('iso_timestamp_start must be before iso_timestamp_end.'); + } + return { iso_timestamp_start: start, iso_timestamp_end: end }; } From 0f271209a20aaf8d0cf4cea977d95a81b535afa1 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Mon, 3 Aug 2026 16:57:20 +0200 Subject: [PATCH 12/18] docs: reframe get_logs guidance without a blanket deprecation notice Both tools currently ship an identical description to every client regardless of platform (hosted vs local/self-hosted), so labeling get_logs "Deprecated" risked a client universally hiding or deprioritizing it, which would break self-hosted users since get_logs is their only working logs tool. Reframe as environment-scoped preference (prefer query_logs on hosted, use get_logs on local/self-hosted) instead of an unqualified deprecation. --- packages/mcp-server-supabase/src/tools/debugging-tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index b45d7ec7..06bb88dd 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -75,7 +75,7 @@ const getAdvisorsOutputSchema = z.object({ export const debuggingToolDefs = { get_logs: { description: - '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.', + '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: { From ab36b09a42e8602dad1aeef413f27a90e242b127 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 4 Aug 2026 16:18:13 +0200 Subject: [PATCH 13/18] fix: make queryLogs optional on DebuggingOperations, gate query_logs registration DebuggingOperations.queryLogs is used by external SupabasePlatform implementers (CLI, studio, mcp.supabase.com controller) outside this repo. Making it required would break them on upgrade: a stale implementer still passes the existing `if (debugging)` group check, so query_logs gets listed in tools/list and then crashes with "debugging.queryLogs is not a function" at call time. Make queryLogs optional and only register the query_logs tool when the platform actually implements it, so an implementer without ClickHouse support (self-hosted/CLI today) simply doesn't get the tool listed instead of erroring. This makes the DebuggingOperations change purely additive. --- .../mcp-server-supabase/src/platform/types.ts | 2 +- .../mcp-server-supabase/src/server.test.ts | 22 +++++++++++++ .../src/tools/debugging-tools.ts | 31 ++++++++++--------- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/mcp-server-supabase/src/platform/types.ts b/packages/mcp-server-supabase/src/platform/types.ts index 276e68a5..c568fcd6 100644 --- a/packages/mcp-server-supabase/src/platform/types.ts +++ b/packages/mcp-server-supabase/src/platform/types.ts @@ -219,7 +219,7 @@ export type EdgeFunctionsOperations = { export type DebuggingOperations = { getLogs(projectId: string, options: GetLogsOptions): Promise; - queryLogs(projectId: string, options: QueryLogsOptions): 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 f52c6b84..efbee56f 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -3977,6 +3977,28 @@ describe('feature groups', () => { 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']); + }); + test('development tools', async () => { const { client } = await setup({ features: ['development'], diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 06bb88dd..72f82687 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -148,6 +148,7 @@ export function getDebuggingTools({ projectId, }: DebuggingToolsOptions) { const project_id = projectId; + const { queryLogs } = debugging; return { get_logs: injectableTool({ @@ -166,21 +167,23 @@ export function getDebuggingTools({ 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, { + ...(queryLogs && { + query_logs: injectableTool({ + ...debuggingToolDefs.query_logs, + inject: { project_id }, + execute: async ({ + project_id, sql, - ...resolveLogWindow(iso_timestamp_start, iso_timestamp_end), - }); - return { result: wrapWithUntrustedDataBoundary(result) }; - }, + 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, From 59526f07ffdbf3630b26b89e9ec8cee0bbf0ab75 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 4 Aug 2026 16:20:33 +0200 Subject: [PATCH 14/18] chore: regenerate management API types --- packages/mcp-server-supabase/src/management-api/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"; }[]; From a8e5a98803c3588b7fddb64a7fd323407bdae2f2 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 4 Aug 2026 16:28:44 +0200 Subject: [PATCH 15/18] fix: validate the log query window, reject empty/inverted ranges resolveLogWindow now: - enforces the 24h API cap client-side with a clear error, instead of relying on an unvalidated description promise - normalizes accepted timestamps to canonical UTC ISO strings before forwarding them, instead of passing the original strings through verbatim - is exported and unit-tested directly (default anchoring, offset normalization, malformed/inverted/oversized-window rejection), covering get_logs and query_logs' shared behavior in one place Also enforces ISO 8601 with an explicit UTC "Z" suffix or offset at the schema level via z.iso.datetime({ offset: true }), so offset-less timestamps (ambiguous local-time interpretation) are rejected before reaching resolveLogWindow, and the constraint shows up in the tool's JSON schema. --- .../src/tools/debugging-tools.test.ts | 71 +++++++++++++++++++ .../src/tools/debugging-tools.ts | 49 +++++++------ 2 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 packages/mcp-server-supabase/src/tools/debugging-tools.test.ts 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 72f82687..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. Defaults to 24 hours before the end of the window. 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. Defaults to the current time. 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.' ), }); @@ -43,17 +43,17 @@ const queryLogsInputSchema = z.object({ .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() + iso_timestamp_start: z.iso + .datetime({ offset: true }) .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.' + '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. Defaults to the current time. 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.' ), }); @@ -114,25 +114,25 @@ export const debuggingToolDefs = { }, } as const satisfies ToolDefs; -const DAY_MS = 24 * 60 * 60 * 1000; +export const DAY_MS = 24 * 60 * 60 * 1000; -function resolveLogWindow( +export function resolveLogWindow( iso_timestamp_start?: string, iso_timestamp_end?: string ) { - const end = iso_timestamp_end ?? new Date().toISOString(); - const endMs = Date.parse(end); + const endMs = iso_timestamp_end ? Date.parse(iso_timestamp_end) : Date.now(); if (Number.isNaN(endMs)) { throw new Error( - `Invalid iso_timestamp_end: "${end}". Expected an ISO 8601 timestamp.` + `Invalid iso_timestamp_end: "${iso_timestamp_end}". Expected an ISO 8601 timestamp.` ); } - const start = iso_timestamp_start ?? new Date(endMs - DAY_MS).toISOString(); - const startMs = Date.parse(start); + const startMs = iso_timestamp_start + ? Date.parse(iso_timestamp_start) + : endMs - DAY_MS; if (Number.isNaN(startMs)) { throw new Error( - `Invalid iso_timestamp_start: "${start}". Expected an ISO 8601 timestamp.` + `Invalid iso_timestamp_start: "${iso_timestamp_start}". Expected an ISO 8601 timestamp.` ); } @@ -140,7 +140,14 @@ function resolveLogWindow( throw new Error('iso_timestamp_start must be before iso_timestamp_end.'); } - return { iso_timestamp_start: start, iso_timestamp_end: 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({ From 286e9dc09f32c470a61cdda1ee2f5c8290745a20 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Tue, 4 Aug 2026 16:33:08 +0200 Subject: [PATCH 16/18] test: strengthen query_logs window assertions - add an equal-timestamps case to the start-at-or-after-end rejection test - assert on the actual rejection message (Invalid ISO datetime, must be before, min-length) instead of a bare rejects.toThrow() - assert the exact default window (end near now, start = end - 24h) instead of just checking the params are truthy --- .../mcp-server-supabase/src/server.test.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index efbee56f..8c996b02 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -2105,6 +2105,7 @@ describe('tools', () => { 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: { @@ -2112,12 +2113,19 @@ describe('tools', () => { sql, }, }); + const after = Date.now(); 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(); + + 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 () => { @@ -2246,7 +2254,7 @@ describe('tools', () => { iso_timestamp_end: 'not-a-timestamp', }, }) - ).rejects.toThrow(); + ).rejects.toThrow(/Invalid ISO datetime/); }); test('query logs rejects a start at or after the end', async () => { @@ -2275,7 +2283,19 @@ describe('tools', () => { iso_timestamp_end: '2024-02-01T10:00:00.000Z', }, }) - ).rejects.toThrow(); + ).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 () => { @@ -2302,7 +2322,7 @@ describe('tools', () => { sql: '', }, }) - ).rejects.toThrow(); + ).rejects.toThrow(/too_small|at least 1 character/); }); test('get security advisors', async () => { From f9da4f3a79cf8b013bab34aee359f35a64864d7f Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 6 Aug 2026 17:48:32 +0200 Subject: [PATCH 17/18] feat!: hide get_logs from tools/list when query_logs is available Per team alignment: on platforms that support ClickHouse-backed querying (hosted/production), query_logs is the tool that shows up in tools/list and get_logs is hidden from discovery (but remains callable via tools/call for compatibility). On platforms without it (CLI/self-hosted), the reverse holds: get_logs is listed and query_logs is not registered at all. Also strips environment-specific wording (hosted, production, self-hosted, CLI) from both tool descriptions, since the description is a frozen field for some clients and shouldn't encode environment context the model can't verify; the routing is now handled structurally via registration/hiding instead of prose. --- .../mcp-server-supabase/src/server.test.ts | 47 ++++++++++++++++--- .../src/tools/debugging-tools.ts | 7 ++- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index 8c996b02..cd8b634f 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -3847,11 +3847,15 @@ describe('tools', () => { // Also verify that the registry doesn't have unexpected extra entries // (tools that don't exist in the server). A registry entry is allowed to - // be missing from tools/list only if its tool def is marked `hidden` — - // it stays in the registry for typed access while being delisted from - // live discovery (see CONTRIBUTING.md's tool deprecation guidance). + // be missing from tools/list if its tool def is marked `hidden` — it + // stays in the registry for typed access while being delisted from live + // discovery (see CONTRIBUTING.md's tool deprecation guidance) — or if + // its visibility is capability-dependent rather than a static def + // property, like get_logs (hidden only when the platform also offers + // query_logs). const registryToolNames = Object.keys(supabaseMcpToolSchemas); const serverToolNames = tools.map((t) => t.name); + const conditionallyHiddenToolNames = new Set(['get_logs']); const extraToolsInRegistry = registryToolNames.filter( (name) => !serverToolNames.includes(name) @@ -3860,7 +3864,7 @@ describe('tools', () => { const unexpectedExtraTools = extraToolsInRegistry.filter( (name) => !supabaseMcpToolSchemas[name as keyof typeof supabaseMcpToolSchemas] - .hidden + .hidden && !conditionallyHiddenToolNames.has(name) ); expect( @@ -3986,7 +3990,7 @@ describe('feature groups', () => { ]); }); - test('debugging tools', async () => { + test('debugging tools hide get_logs in favor of query_logs when the platform supports it', async () => { const { client } = await setup({ features: ['debugging'], }); @@ -3994,10 +3998,39 @@ describe('feature groups', () => { const { tools } = await client.listTools(); const toolNames = tools.map((tool) => tool.name); - expect(toolNames).toEqual(['get_logs', 'query_logs', 'get_advisors']); + expect(toolNames).toEqual(['query_logs', 'get_advisors']); }); - test('debugging tools omit query_logs when the platform does not implement it', async () => { + test('get_logs stays callable via tools/call even while hidden from tools/list', async () => { + const { callTool } = await setup({ + features: ['debugging'], + }); + + 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 callTool({ + name: 'get_logs', + arguments: { + project_id: project.id, + service: 'api', + }, + }); + + expect(result).toContain('untrusted-data'); + }); + + test('debugging tools show get_logs when the platform does not implement query_logs', async () => { const platform: SupabasePlatform = { debugging: { getLogs() { diff --git a/packages/mcp-server-supabase/src/tools/debugging-tools.ts b/packages/mcp-server-supabase/src/tools/debugging-tools.ts index 7d39728f..84b04caf 100644 --- a/packages/mcp-server-supabase/src/tools/debugging-tools.ts +++ b/packages/mcp-server-supabase/src/tools/debugging-tools.ts @@ -75,7 +75,7 @@ const getAdvisorsOutputSchema = z.object({ export const debuggingToolDefs = { get_logs: { description: - '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.', + '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.', parameters: getLogsInputSchema, outputSchema: getLogsOutputSchema, annotations: { @@ -88,7 +88,7 @@ export const debuggingToolDefs = { }, 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.", + "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 a simple per-service log dump. 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: { @@ -160,6 +160,9 @@ export function getDebuggingTools({ return { get_logs: injectableTool({ ...debuggingToolDefs.get_logs, + // query_logs supersedes get_logs wherever ClickHouse-backed querying is + // available; keep get_logs callable for platforms/clients without it. + hidden: Boolean(queryLogs), inject: { project_id }, execute: async ({ project_id, From 9187f41195d40818868668d1476d653285f21e06 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 6 Aug 2026 19:06:05 +0200 Subject: [PATCH 18/18] chore: retrigger CI after GitHub Actions runner outage