diff --git a/apps/public/content/docs/mcp/index.mdx b/apps/public/content/docs/mcp/index.mdx index 5237e1541..fe9a720ce 100644 --- a/apps/public/content/docs/mcp/index.mdx +++ b/apps/public/content/docs/mcp/index.mdx @@ -107,9 +107,24 @@ claude mcp add --transport http openpanel "https://api.openpanel.dev/mcp?token=Y | Tool | Description | |------|-------------| | `list_dashboards` | List all dashboards for a project. | +| `get_dashboard` | Get a dashboard with its complete report configurations and saved layouts. | | `list_reports` | List all reports in a dashboard with their chart types and tracked events. | | `get_report_data` | Execute a saved report and return its data (time-series, funnel, metric, etc.). | +### Dashboard management (root clients) + +| Tool | Description | +|------|-------------| +| `create_dashboard` | Create a dashboard in a project. | +| `update_dashboard` | Rename a dashboard. | +| `delete_dashboard` | Delete an empty dashboard, or delete it with its reports using `forceDelete`. | +| `create_report` | Add a saved chart to a dashboard. | +| `update_report` | Replace a saved chart's configuration. | +| `delete_report` | Delete a saved chart. | +| `duplicate_report` | Duplicate a saved chart in its dashboard. | +| `update_report_layout` | Set a saved chart's dashboard grid position and dimensions. | +| `reset_dashboard_layout` | Remove all saved chart layouts from a dashboard. | + ### Discovery | Tool | Description | diff --git a/packages/mcp/src/tools/dashboard-management.test.ts b/packages/mcp/src/tools/dashboard-management.test.ts new file mode 100644 index 000000000..52500e802 --- /dev/null +++ b/packages/mcp/src/tools/dashboard-management.test.ts @@ -0,0 +1,476 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +const mockDb = vi.hoisted(() => ({ + $transaction: vi.fn(), + dashboard: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + report: { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + }, + reportLayout: { + upsert: vi.fn(), + deleteMany: vi.fn(), + }, +})); + +const mockGetDashboardById = vi.hoisted(() => vi.fn()); +const mockGetProjectById = vi.hoisted(() => vi.fn()); +const mockGetId = vi.hoisted(() => vi.fn()); + +vi.mock('@openpanel/db', () => ({ + Prisma: { DbNull: { kind: 'DbNull' } }, + db: mockDb, + getDashboardById: mockGetDashboardById, + getProjectById: mockGetProjectById, + getId: mockGetId, + resolveClientProjectId: vi.fn( + ({ + clientProjectId, + inputProjectId, + }: { + clientProjectId: string | null; + inputProjectId?: string; + }) => Promise.resolve(clientProjectId ?? inputProjectId), + ), +})); + +import { registerDashboardManagementTools } from './dashboard-management'; + +type Handler = (input: any) => Promise; + +function makeServer() { + const handlers = new Map(); + const schemas = new Map(); + return { + tool: ( + name: string, + _description: string, + schema: any, + handler: Handler, + ) => { + handlers.set(name, handler); + schemas.set(name, schema); + }, + invoke: async (name: string, input: any) => { + const schema = schemas.get(name); + const parsed = z.object(schema).parse(input); + const result = await handlers.get(name)!(parsed); + const text = result.content[0].text; + return result.isError ? { error: text } : JSON.parse(text); + }, + schema(name: string) { + return schemas.get(name); + }, + names() { + return [...schemas.keys()]; + }, + }; +} + +const READ_CONTEXT = { + projectId: 'project-1', + organizationId: 'organization-1', + clientType: 'read' as const, +}; + +const ROOT_CONTEXT = { + projectId: null, + organizationId: 'organization-1', + clientType: 'root' as const, +}; + +const DASHBOARD = { + id: 'dashboard-1', + projectId: 'project-1', + organizationId: 'organization-1', + name: 'Product', + project: { id: 'project-1' }, +}; + +const REPORT = { + id: 'report-1', + projectId: 'project-1', + dashboardId: 'dashboard-1', + name: 'Signups', + events: [], + globalFilters: [], + interval: 'day', + breakdowns: [], + chartType: 'linear', + lineType: 'monotone', + range: '30d', + formula: null, + previous: false, + unit: null, + metric: 'sum', + options: null, + visibleSeries: [], + startDate: null, + endDate: null, + layout: null, +}; + +const EVENT_WITH_TYPED_COHORT_FILTER = { + type: 'event', + name: 'signup', + segment: 'event', + filters: [ + { + id: 'A', + name: 'plan', + operator: 'is', + value: ['pro'], + type: 'string', + cohortId: 'legacy-cohort', + cohortIds: ['cohort-1', 'cohort-2'], + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetDashboardById.mockResolvedValue(DASHBOARD); + mockGetProjectById.mockResolvedValue({ + id: 'project-1', + organizationId: 'organization-1', + }); + mockGetId.mockResolvedValue('dashboard-2'); + mockDb.dashboard.create.mockResolvedValue({ ...DASHBOARD, id: 'dashboard-2' }); + mockDb.dashboard.findFirst.mockResolvedValue(DASHBOARD); + mockDb.dashboard.update.mockResolvedValue({ ...DASHBOARD, name: 'Renamed' }); + mockDb.report.findFirst.mockResolvedValue(REPORT); + mockDb.report.findMany.mockResolvedValue([]); + mockDb.report.create.mockResolvedValue(REPORT); + mockDb.report.update.mockResolvedValue(REPORT); + mockDb.report.delete.mockResolvedValue(REPORT); + mockDb.report.deleteMany.mockResolvedValue({ count: 1 }); + mockDb.reportLayout.upsert.mockResolvedValue({ reportId: REPORT.id, x: 1 }); + mockDb.reportLayout.deleteMany.mockResolvedValue({ count: 1 }); + mockDb.$transaction.mockImplementation(async (callback) => callback(mockDb)); +}); + +function register(context = ROOT_CONTEXT) { + const server = makeServer(); + registerDashboardManagementTools(server as any, context); + return server; +} + +function validReport(overrides: Record = {}) { + return { + name: 'Signups', + series: [], + ...overrides, + }; +} + +describe('dashboard management registration', () => { + it('registers only get_dashboard for read credentials', () => { + expect(register(READ_CONTEXT).names()).toEqual(['get_dashboard']); + }); + + it('registers all management tools for root credentials', () => { + expect(register().names()).toEqual([ + 'get_dashboard', + 'create_dashboard', + 'update_dashboard', + 'delete_dashboard', + 'create_report', + 'update_report', + 'delete_report', + 'duplicate_report', + 'update_report_layout', + 'reset_dashboard_layout', + ]); + }); + + it('uses a strict persistable report schema with defaults', () => { + const server = register(); + const reportSchema = server.schema('create_report').report; + + expect(reportSchema.parse(validReport())).toMatchObject({ + chartType: 'linear', + interval: 'day', + range: '30d', + previous: false, + metric: 'sum', + lineType: 'monotone', + }); + expect(reportSchema.safeParse({ name: 'Missing series' }).success).toBe(false); + expect( + reportSchema.safeParse(validReport({ limit: 10 })).success, + ).toBe(false); + expect( + reportSchema.safeParse(validReport({ offset: 10 })).success, + ).toBe(false); + }); + + it('requires valid ordered dates for custom ranges', () => { + const reportSchema = register().schema('create_report').report; + + expect( + reportSchema.safeParse(validReport({ range: 'custom' })).success, + ).toBe(false); + expect( + reportSchema.safeParse( + validReport({ + range: 'custom', + startDate: '2026-02-30', + endDate: '2026-03-01', + }), + ).success, + ).toBe(false); + expect( + reportSchema.safeParse( + validReport({ + range: 'custom', + startDate: '2026-03-02', + endDate: '2026-03-01', + }), + ).success, + ).toBe(false); + }); +}); + +describe('dashboard management project binding', () => { + it('binds dashboard reads to the resolved project', async () => { + const server = register(READ_CONTEXT); + + await server.invoke('get_dashboard', { + projectId: 'another-project', + dashboardId: 'dashboard-1', + }); + + expect(mockGetDashboardById).toHaveBeenCalledWith('dashboard-1', 'project-1'); + expect(mockDb.report.findMany).toHaveBeenCalledWith({ + where: { dashboardId: 'dashboard-1' }, + include: { layout: true }, + }); + }); + + it('does not mutate a report from another project', async () => { + mockDb.report.findFirst.mockResolvedValue(null); + const server = register(); + + const result = await server.invoke('delete_report', { + projectId: 'project-1', + reportId: 'foreign-report', + }); + + expect(result.error).toContain('Report not found'); + expect(mockDb.report.delete).not.toHaveBeenCalled(); + expect(mockDb.report.findFirst).toHaveBeenCalledWith({ + where: { id: 'foreign-report', projectId: 'project-1' }, + }); + }); +}); + +describe('dashboard management behavior', () => { + it('persists custom dates and router defaults after schema parsing', async () => { + const server = register(); + + await server.invoke('create_report', { + projectId: 'project-1', + dashboardId: 'dashboard-1', + report: validReport({ + range: 'custom', + startDate: '2026-01-01', + endDate: '2026-01-31', + }), + }); + + expect(mockDb.report.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + projectId: 'project-1', + dashboardId: 'dashboard-1', + globalFilters: [], + visibleSeries: [], + startDate: '2026-01-01', + endDate: '2026-01-31', + }), + }); + }); + + it('clears optional persisted report values on full replacement', async () => { + const server = register(); + + await server.invoke('update_report', { + projectId: 'project-1', + reportId: 'report-1', + report: validReport(), + }); + + expect(mockDb.report.update).toHaveBeenCalledWith({ + where: { id: 'report-1' }, + data: expect.objectContaining({ + formula: null, + unit: null, + options: expect.anything(), + startDate: null, + endDate: null, + }), + }); + }); + + it('returns a lossless report configuration for get→update round trips', async () => { + const report = { + ...REPORT, + events: [EVENT_WITH_TYPED_COHORT_FILTER], + globalFilters: [ + { + name: 'country', + operator: 'is', + value: ['SE'], + type: 'string', + cohortIds: ['cohort-global'], + }, + ], + }; + mockDb.report.findMany.mockResolvedValue([report]); + const readServer = register(READ_CONTEXT); + const dashboard = await readServer.invoke('get_dashboard', { + dashboardId: 'dashboard-1', + }); + const configuration = dashboard.reports[0].report; + + expect(configuration.series).toEqual(report.events); + expect(configuration.globalFilters).toEqual(report.globalFilters); + + const rootServer = register(); + await rootServer.invoke('update_report', { + projectId: 'project-1', + reportId: 'report-1', + report: configuration, + }); + + expect(mockDb.report.update).toHaveBeenCalledWith({ + where: { id: 'report-1' }, + data: expect.objectContaining({ + events: report.events, + globalFilters: report.globalFilters, + }), + }); + }); + + it('rejects a non-empty dashboard atomically without force', async () => { + mockDb.report.findMany.mockResolvedValue([ + { id: 'report-1', projectId: 'project-1' }, + ]); + const server = register(); + + const result = await server.invoke('delete_dashboard', { + projectId: 'project-1', + dashboardId: 'dashboard-1', + }); + + expect(result.error).toContain('Cannot delete dashboard with associated reports'); + expect(mockDb.$transaction).toHaveBeenCalled(); + expect(mockDb.dashboard.delete).not.toHaveBeenCalled(); + }); + + it('force deletes reports and their layouts in one transaction', async () => { + mockDb.report.findMany.mockResolvedValue([ + { id: 'report-1', projectId: 'project-1' }, + ]); + const server = register(); + + await server.invoke('delete_dashboard', { + projectId: 'project-1', + dashboardId: 'dashboard-1', + forceDelete: true, + }); + + expect(mockDb.report.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['report-1'] } }, + }); + expect(mockDb.reportLayout.deleteMany).toHaveBeenCalledWith({ + where: { reportId: { in: ['report-1'] } }, + }); + expect(mockDb.dashboard.delete).toHaveBeenCalledWith({ + where: { id: 'dashboard-1' }, + }); + }); + + it('duplicates a bound report without losing custom dates', async () => { + mockDb.report.findFirst.mockResolvedValue({ + ...REPORT, + events: [EVENT_WITH_TYPED_COHORT_FILTER], + range: 'custom', + startDate: '2026-01-01', + endDate: '2026-01-31', + }); + const server = register(); + + await server.invoke('duplicate_report', { + projectId: 'project-1', + reportId: 'report-1', + }); + + expect(mockDb.report.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: 'Copy of Signups', + startDate: '2026-01-01', + endDate: '2026-01-31', + events: [EVENT_WITH_TYPED_COHORT_FILTER], + }), + }); + }); + + it('scopes reset layout deletion to the resolved project dashboard', async () => { + const server = register(); + + await server.invoke('reset_dashboard_layout', { + projectId: 'project-1', + dashboardId: 'dashboard-1', + }); + + expect(mockDb.reportLayout.deleteMany).toHaveBeenCalledWith({ + where: { + report: { dashboardId: 'dashboard-1', projectId: 'project-1' }, + }, + }); + }); + + it('rejects invalid layouts before the handler executes and persists valid ones', async () => { + const server = register(); + + await expect( + server.invoke('update_report_layout', { + projectId: 'project-1', + reportId: 'report-1', + layout: { x: -1, y: 0, w: 4, h: 3 }, + }), + ).rejects.toThrow(); + expect(mockDb.reportLayout.upsert).not.toHaveBeenCalled(); + + await server.invoke('update_report_layout', { + projectId: 'project-1', + reportId: 'report-1', + layout: { x: 1, y: 2, w: 4, h: 3, minW: 2, minH: 2, maxW: 8, maxH: 8 }, + }); + expect(mockDb.reportLayout.upsert).toHaveBeenCalledWith({ + where: { reportId: 'report-1' }, + create: { + reportId: 'report-1', + x: 1, + y: 2, + w: 4, + h: 3, + minW: 2, + minH: 2, + maxW: 8, + maxH: 8, + }, + update: { x: 1, y: 2, w: 4, h: 3, minW: 2, minH: 2, maxW: 8, maxH: 8 }, + }); + }); +}); diff --git a/packages/mcp/src/tools/dashboard-management.ts b/packages/mcp/src/tools/dashboard-management.ts new file mode 100644 index 000000000..ea39ec4a9 --- /dev/null +++ b/packages/mcp/src/tools/dashboard-management.ts @@ -0,0 +1,561 @@ +import { + Prisma, + db, + getDashboardById, + getId, + getProjectById, +} from '@openpanel/db'; +import { zReport } from '@openpanel/validation'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../auth'; +import { dashboardBaseUrl } from './dashboard-links'; +import { projectIdSchema, resolveProjectId, withErrorHandling } from './shared'; + +const reportSchema = zReport + .omit({ projectId: true, limit: true, offset: true }) + .strict() + .superRefine((report, ctx) => { + if (report.range !== 'custom') { + return; + } + + const dates = [ + ['startDate', report.startDate], + ['endDate', report.endDate], + ] as const; + + for (const [field, value] of dates) { + if (!isValidDateOnly(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} is required in YYYY-MM-DD format for custom ranges`, + }); + } + } + + if ( + isValidDateOnly(report.startDate) && + isValidDateOnly(report.endDate) && + report.startDate! > report.endDate! + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['endDate'], + message: 'endDate must be on or after startDate', + }); + } + }); + +const layoutSchema = z.object({ + x: z.number().int().nonnegative(), + y: z.number().int().nonnegative(), + w: z.number().int().positive(), + h: z.number().int().positive(), + minW: z.number().int().positive().optional(), + minH: z.number().int().positive().optional(), + maxW: z.number().int().positive().optional(), + maxH: z.number().int().positive().optional(), +}).superRefine((layout, ctx) => { + if ( + layout.minW !== undefined && + layout.maxW !== undefined && + layout.maxW < layout.minW + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['maxW'], + message: 'maxW must be greater than or equal to minW', + }); + } + if (layout.minW !== undefined && layout.minW > layout.w) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minW'], + message: 'minW must be less than or equal to w', + }); + } + if (layout.maxW !== undefined && layout.maxW < layout.w) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['maxW'], + message: 'maxW must be greater than or equal to w', + }); + } + if ( + layout.minH !== undefined && + layout.maxH !== undefined && + layout.maxH < layout.minH + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['maxH'], + message: 'maxH must be greater than or equal to minH', + }); + } + if (layout.minH !== undefined && layout.minH > layout.h) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['minH'], + message: 'minH must be less than or equal to h', + }); + } + if (layout.maxH !== undefined && layout.maxH < layout.h) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['maxH'], + message: 'maxH must be greater than or equal to h', + }); + } +}); + +function isValidDateOnly(value: unknown): value is string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function dashboardUrl(organizationId: string, projectId: string, dashboardId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/dashboards/${dashboardId}`; +} + +function reportUrl(organizationId: string, projectId: string, reportId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/reports/${reportId}`; +} + +function reportData(report: z.infer) { + return { + name: report.name, + events: report.series, + globalFilters: report.globalFilters ?? [], + interval: report.interval, + breakdowns: report.breakdowns, + chartType: report.chartType, + lineType: report.lineType, + range: report.range, + formula: report.formula ?? null, + previous: report.previous ?? false, + unit: report.unit ?? null, + metric: report.metric, + options: report.options ?? Prisma.DbNull, + visibleSeries: report.visibleSeries ?? [], + startDate: report.range === 'custom' ? report.startDate : null, + endDate: report.range === 'custom' ? report.endDate : null, + }; +} + +async function requireDashboard(projectId: string, dashboardId: string) { + const dashboard = await getDashboardById(dashboardId, projectId); + if (!dashboard) { + throw new Error('Dashboard not found'); + } + return dashboard; +} + +async function requireReport(projectId: string, reportId: string) { + const report = await db.report.findFirst({ + where: { id: reportId, projectId }, + }); + if (!report) { + throw new Error('Report not found'); + } + return report; +} + +function withDashboardUrl( + organizationId: string, + projectId: string, + dashboard: { id: string }, +) { + return { + ...dashboard, + dashboard_url: dashboardUrl(organizationId, projectId, dashboard.id), + }; +} + +function withReportUrl( + organizationId: string, + projectId: string, + report: { id: string }, +) { + return { + ...report, + // Keep the existing MCP report-tool field name for this report URL. + dashboard_url: reportUrl(organizationId, projectId, report.id), + }; +} + +function canonicalReportConfig(report: { + name: string; + events: unknown; + globalFilters: unknown; + interval: string; + breakdowns: unknown; + chartType: string; + lineType: string; + range: string; + formula: string | null; + previous: boolean; + unit: string | null; + metric: string; + options: unknown; + visibleSeries: string[]; + startDate: string | null; + endDate: string | null; +}) { + return { + name: report.name, + series: report.events, + globalFilters: report.globalFilters, + interval: report.interval, + breakdowns: report.breakdowns, + chartType: report.chartType, + lineType: report.lineType, + range: report.range, + startDate: report.startDate, + endDate: report.endDate, + previous: report.previous, + formula: report.formula ?? undefined, + metric: report.metric, + unit: report.unit ?? undefined, + options: report.options ?? undefined, + visibleSeries: report.visibleSeries, + }; +} + +export function registerDashboardManagementTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_dashboard', + 'Get a dashboard, all of its reports, and their saved layouts.', + { + projectId: projectIdSchema(context), + dashboardId: z.string().describe('The dashboard ID to retrieve'), + }, + async ({ projectId: inputProjectId, dashboardId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const dashboard = await requireDashboard(projectId, dashboardId); + const reports = await db.report.findMany({ + where: { dashboardId }, + include: { layout: true }, + }); + if (reports.some((report) => report.projectId !== projectId)) { + throw new Error('Dashboard contains a report from another project'); + } + const reportsWithUrls = reports.map((report) => ({ + id: report.id, + report: canonicalReportConfig(report), + layout: report.layout, + dashboard_url: reportUrl(context.organizationId, projectId, report.id), + })); + + return { + dashboard: withDashboardUrl(context.organizationId, projectId, dashboard), + reports: reportsWithUrls, + layouts: reportsWithUrls.flatMap((report) => + report.layout ? [report.layout] : [], + ), + }; + }), + ); + + if (context.clientType !== 'root') { + return; + } + + server.tool( + 'create_dashboard', + 'Create a dashboard in the resolved project.', + { + projectId: projectIdSchema(context), + name: z.string().describe('The dashboard name'), + }, + async ({ projectId: inputProjectId, name }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const project = await getProjectById(projectId); + if (!project) { + throw new Error('Project not found'); + } + + const dashboard = await db.dashboard.create({ + data: { + id: await getId('dashboard', name), + projectId, + organizationId: project.organizationId, + name, + }, + }); + + return { + dashboard: withDashboardUrl(context.organizationId, projectId, dashboard), + reports: [], + layouts: [], + }; + }), + ); + + server.tool( + 'update_dashboard', + 'Rename a dashboard after verifying it belongs to the resolved project.', + { + projectId: projectIdSchema(context), + dashboardId: z.string().describe('The dashboard ID to update'), + name: z.string().describe('The new dashboard name'), + }, + async ({ projectId: inputProjectId, dashboardId, name }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + await requireDashboard(projectId, dashboardId); + const dashboard = await db.dashboard.update({ + where: { id: dashboardId }, + data: { name }, + }); + + return { + dashboard: withDashboardUrl(context.organizationId, projectId, dashboard), + }; + }), + ); + + server.tool( + 'delete_dashboard', + 'Delete a dashboard. Deletion fails when reports exist unless forceDelete is true; forced deletion removes the reports first.', + { + projectId: projectIdSchema(context), + dashboardId: z.string().describe('The dashboard ID to delete'), + forceDelete: z + .boolean() + .optional() + .describe('Delete all reports in the dashboard before deleting it'), + }, + async ({ projectId: inputProjectId, dashboardId, forceDelete }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const dashboard = await requireDashboard(projectId, dashboardId); + + try { + await db.$transaction( + async (transaction) => { + const lockedDashboard = await transaction.dashboard.findFirst({ + where: { id: dashboardId, projectId }, + }); + if (!lockedDashboard) { + throw new Error('Dashboard not found'); + } + + const reports = await transaction.report.findMany({ + where: { dashboardId }, + select: { id: true, projectId: true }, + }); + if (reports.some((report) => report.projectId !== projectId)) { + throw new Error('Dashboard contains a report from another project'); + } + if (reports.length > 0 && !forceDelete) { + throw new Error('Cannot delete dashboard with associated reports'); + } + + if (forceDelete && reports.length > 0) { + const reportIds = reports.map((report) => report.id); + await transaction.report.deleteMany({ + where: { id: { in: reportIds } }, + }); + // Keep this explicit even though the current schema cascades + // layouts from reports, so forced deletion cannot leave state + // behind if that relationship changes. + await transaction.reportLayout.deleteMany({ + where: { reportId: { in: reportIds } }, + }); + } + + await transaction.dashboard.delete({ where: { id: dashboardId } }); + }, + { isolationLevel: 'Serializable' }, + ); + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'P2003' + ) { + throw new Error('Cannot delete dashboard with associated reports'); + } + if (error instanceof Error) { + throw error; + } + throw new Error('Unknown error deleting dashboard'); + } + + return { + deleted: true, + dashboard: withDashboardUrl(context.organizationId, projectId, dashboard), + }; + }), + ); + + server.tool( + 'create_report', + 'Create a saved chart report in a dashboard using the canonical report configuration shape.', + { + projectId: projectIdSchema(context), + dashboardId: z.string().describe('The dashboard ID for the new report'), + report: reportSchema.describe('The saved report configuration'), + }, + async ({ projectId: inputProjectId, dashboardId, report }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const dashboard = await requireDashboard(projectId, dashboardId); + const created = await db.report.create({ + data: { + projectId: dashboard.projectId, + dashboardId, + ...reportData(report), + }, + }); + + return { + report: withReportUrl(context.organizationId, projectId, created), + }; + }), + ); + + server.tool( + 'update_report', + 'Update a saved chart report after verifying it belongs to the resolved project.', + { + projectId: projectIdSchema(context), + reportId: z.string().describe('The report ID to update'), + report: reportSchema.describe('The complete saved report configuration'), + }, + async ({ projectId: inputProjectId, reportId, report }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + await requireReport(projectId, reportId); + const updated = await db.report.update({ + where: { id: reportId }, + data: reportData(report), + }); + + return { + report: withReportUrl(context.organizationId, projectId, updated), + }; + }), + ); + + server.tool( + 'delete_report', + 'Delete a saved chart report after verifying it belongs to the resolved project.', + { + projectId: projectIdSchema(context), + reportId: z.string().describe('The report ID to delete'), + }, + async ({ projectId: inputProjectId, reportId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + await requireReport(projectId, reportId); + const deleted = await db.report.delete({ where: { id: reportId } }); + + return { + deleted: true, + report: withReportUrl(context.organizationId, projectId, deleted), + }; + }), + ); + + server.tool( + 'duplicate_report', + 'Duplicate a saved report in its existing dashboard.', + { + projectId: projectIdSchema(context), + reportId: z.string().describe('The report ID to duplicate'), + }, + async ({ projectId: inputProjectId, reportId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const report = await requireReport(projectId, reportId); + const duplicate = await db.report.create({ + data: { + projectId: report.projectId, + dashboardId: report.dashboardId, + name: `Copy of ${report.name}`, + events: report.events!, + globalFilters: report.globalFilters ?? [], + interval: report.interval, + breakdowns: report.breakdowns!, + chartType: report.chartType, + lineType: report.lineType, + range: report.range, + formula: report.formula, + previous: report.previous, + unit: report.unit, + metric: report.metric, + options: report.options, + visibleSeries: report.visibleSeries, + startDate: report.startDate, + endDate: report.endDate, + }, + }); + + return { + report: withReportUrl(context.organizationId, projectId, duplicate), + }; + }), + ); + + server.tool( + 'update_report_layout', + 'Save or update the grid layout for a report in the resolved project.', + { + projectId: projectIdSchema(context), + reportId: z.string().describe('The report ID whose layout should change'), + layout: layoutSchema.describe('The report grid layout'), + }, + async ({ projectId: inputProjectId, reportId, layout }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + await requireReport(projectId, reportId); + return db.reportLayout.upsert({ + where: { reportId }, + create: { reportId, ...layout }, + update: layout, + }); + }), + ); + + server.tool( + 'reset_dashboard_layout', + 'Delete all saved report layouts in a dashboard after binding it to the resolved project.', + { + projectId: projectIdSchema(context), + dashboardId: z.string().describe('The dashboard whose layouts should reset'), + }, + async ({ projectId: inputProjectId, dashboardId }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + await requireDashboard(projectId, dashboardId); + const result = await db.reportLayout.deleteMany({ + where: { + report: { + dashboardId, + projectId, + }, + }, + }); + + return { + dashboardId, + count: result.count, + deletedLayouts: result.count, + dashboard_url: dashboardUrl(context.organizationId, projectId, dashboardId), + }; + }), + ); +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 40acdcbcf..6a879e596 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -24,6 +24,7 @@ import { registerGscOverviewTools } from './gsc/overview'; import { registerGscPageTools } from './gsc/pages'; import { registerGscQueryTools } from './gsc/queries'; import { registerDashboardLinkTools } from './dashboard-links'; +import { registerDashboardManagementTools } from './dashboard-management'; import { registerProjectTools } from './projects'; export function registerAllTools( @@ -33,6 +34,7 @@ export function registerAllTools( // Project access — always call first to discover available projects registerProjectTools(server, context); registerDashboardLinkTools(server, context); + registerDashboardManagementTools(server, context); registerReportTools(server, context); // Analytics — discovery (call these first to understand the data)