Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/mcp-server-supabase/src/management-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}[];
Expand All @@ -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";
}[];
Expand Down
26 changes: 26 additions & 0 deletions packages/mcp-server-supabase/src/platform/api-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
deployEdgeFunctionOptionsSchema,
executeSqlOptionsSchema,
getLogsOptionsSchema,
queryLogsOptionsSchema,
resetBranchOptionsSchema,
type AccountOperations,
type ApiKey,
Expand All @@ -38,6 +39,7 @@ import {
type EdgeFunctionWithBody,
type ExecuteSqlOptions,
type GetLogsOptions,
type QueryLogsOptions,
type ResetBranchOptions,
type StorageConfig,
type StorageOperations,
Expand Down Expand Up @@ -277,6 +279,30 @@ export function createSupabaseApiPlatform(

return response.data;
},
async queryLogs(projectId: string, options: QueryLogsOptions) {
Comment thread
jordienr marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Does /v1/.../analytics/endpoints/logs hard-rejects non-SELECT statements?

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',
Expand Down
8 changes: 8 additions & 0 deletions packages/mcp-server-supabase/src/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand All @@ -172,6 +178,7 @@ export type ListMigrationsResult = z.infer<typeof migrationSchema>;

export type LogsService = z.infer<typeof logsServiceSchema>;
export type GetLogsOptions = z.infer<typeof getLogsOptionsSchema>;
export type QueryLogsOptions = z.infer<typeof queryLogsOptionsSchema>;
export type GenerateTypescriptTypesResult = z.infer<
typeof generateTypescriptTypesResultSchema
>;
Expand Down Expand Up @@ -212,6 +219,7 @@ export type EdgeFunctionsOperations = {

export type DebuggingOperations = {
getLogs(projectId: string, options: GetLogsOptions): Promise<unknown>;
queryLogs?(projectId: string, options: QueryLogsOptions): Promise<unknown>;
getSecurityAdvisors(projectId: string): Promise<unknown>;
getPerformanceAdvisors(projectId: string): Promise<unknown>;
};
Expand Down
275 changes: 275 additions & 0 deletions packages/mcp-server-supabase/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Adding the equal-timestamps case, and giving these negative tests a message matcher (rejects.toThrow(/must be before/)) instead of bare rejects.toThrow(), would make them mean what they say.

Same theme at line 2075: the default-window assertions only check the params are truthy, an exact assertion would catch a wrong default or swapped bounds.

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();

Expand Down Expand Up @@ -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']);
});

Expand Down
Loading
Loading