diff --git a/specs/0007-google-analytics-mcp-integration.md b/specs/0007-google-analytics-mcp-integration.md index e70522d..7e52099 100644 --- a/specs/0007-google-analytics-mcp-integration.md +++ b/specs/0007-google-analytics-mcp-integration.md @@ -127,12 +127,14 @@ common input: | `offset` | Non-negative integer; default 0 | With no explicit dates, the range is the last 28 complete days in the GA4 -property time zone. Explicit ranges are inclusive. The report builder caps the -end at the last complete property day and moves the start forward when the -range exceeds 90 days. The response returns requested and resolved dates plus -`end_date_clamped` or `start_date_clamped` warnings. Invalid date formats, -reversed dates, and a single date without its pair return `validation_error` -before an API call. +property time zone. Explicit ranges are inclusive and honored in full; there is +no maximum range. The report builder caps the end at the last complete property +day. The response returns requested and resolved dates plus an +`end_date_clamped` warning, which the text output also states. The organic +overview trend is capped at 1,000 rows and reports `trend_truncated` (also +stated in the text) when a range exceeds that. Invalid date +formats, reversed dates, and a single date without its pair return +`validation_error` before an API call. Only these tool-specific inputs are accepted: diff --git a/src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts b/src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts index 10bd0f7..25959f5 100644 --- a/src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts +++ b/src/server/features/ga4/services/Ga4OrganicOverviewService.test.ts @@ -36,7 +36,7 @@ describe("Ga4OrganicOverviewService", () => { mocks.getByProjectId.mockResolvedValue(connection); }); - it("returns an equal-length comparison and weekly trend", async () => { + it("returns an equal-length comparison and flags a truncated trend", async () => { mocks.runReport .mockResolvedValueOnce({ dimensionHeaders: [], @@ -93,7 +93,7 @@ describe("Ga4OrganicOverviewService", () => { ]), }, ], - rowCount: 1, + rowCount: 1200, }); const result = await Ga4OrganicOverviewService.getOrganicOverview( { @@ -120,6 +120,7 @@ describe("Ga4OrganicOverviewService", () => { sessions: 100, }); expect(result.diagnostics).toEqual([]); + expect(result.warnings).toEqual(["trend_truncated"]); expect(mocks.runReport).toHaveBeenCalledTimes(3); }); diff --git a/src/server/features/ga4/services/Ga4OrganicOverviewService.ts b/src/server/features/ga4/services/Ga4OrganicOverviewService.ts index 058cdbf..0450521 100644 --- a/src/server/features/ga4/services/Ga4OrganicOverviewService.ts +++ b/src/server/features/ga4/services/Ga4OrganicOverviewService.ts @@ -142,7 +142,12 @@ async function getOrganicOverview( reports: reports.map((report) => report.reportMetadata), }, quota: trendReport.quota ?? current.quota, - warnings: dateRange.warnings, + warnings: [ + ...dateRange.warnings, + ...(trendReport.totalRowCount > trendReport.rows.length + ? ["trend_truncated"] + : []), + ], }; } catch (error) { mapGa4ReportError(error); diff --git a/src/server/features/ga4/services/Ga4ReportingService.test.ts b/src/server/features/ga4/services/Ga4ReportingService.test.ts index 1ab8dfb..b7455fe 100644 --- a/src/server/features/ga4/services/Ga4ReportingService.test.ts +++ b/src/server/features/ga4/services/Ga4ReportingService.test.ts @@ -134,7 +134,7 @@ describe("Ga4ReportingService", () => { }); }); - it("clamps explicit dates and nulls restricted metrics", async () => { + it("clamps a future endDate, keeps a long startDate, and nulls restricted metrics", async () => { mocks.runReport.mockResolvedValue({ ...landingHeaders, rows: [ @@ -168,10 +168,10 @@ describe("Ga4ReportingService", () => { ); expect(result.request.resolvedDateRange).toEqual({ - startDate: "2026-05-08", + startDate: "2025-01-01", endDate: "2026-08-05", }); - expect(result.warnings).toEqual(["end_date_clamped", "start_date_clamped"]); + expect(result.warnings).toEqual(["end_date_clamped"]); expect(result.rows[0]?.purchaseRevenue).toBeNull(); }); diff --git a/src/server/features/ga4/services/Ga4ReportingService.ts b/src/server/features/ga4/services/Ga4ReportingService.ts index 9b3eb77..d62b0b4 100644 --- a/src/server/features/ga4/services/Ga4ReportingService.ts +++ b/src/server/features/ga4/services/Ga4ReportingService.ts @@ -87,17 +87,12 @@ export function resolveGa4DateRange( -1, ); let endDate = requestedDateRange?.endDate ?? lastCompleteDay; - let startDate = requestedDateRange?.startDate ?? shiftGa4Date(endDate, -27); + const startDate = requestedDateRange?.startDate ?? shiftGa4Date(endDate, -27); const warnings: string[] = []; if (endDate > lastCompleteDay) { endDate = lastCompleteDay; warnings.push("end_date_clamped"); } - const ninetyDayFloor = shiftGa4Date(endDate, -89); - if (startDate < ninetyDayFloor) { - startDate = ninetyDayFloor; - warnings.push("start_date_clamped"); - } if (startDate > endDate) { throw new Ga4ReportError( "validation_error", diff --git a/src/server/mcp/tools/google-analytics-tools.test.ts b/src/server/mcp/tools/google-analytics-tools.test.ts index 912e5f4..b94c225 100644 --- a/src/server/mcp/tools/google-analytics-tools.test.ts +++ b/src/server/mcp/tools/google-analytics-tools.test.ts @@ -253,6 +253,7 @@ describe("Google Analytics MCP tools", () => { }, comparison: {}, trend: [], + warnings: [], }); mocks.getMeasurementHealth.mockResolvedValue({ status: "ok", diff --git a/src/server/mcp/tools/google-analytics-tools.ts b/src/server/mcp/tools/google-analytics-tools.ts index fda37e8..a5899f3 100644 --- a/src/server/mcp/tools/google-analytics-tools.ts +++ b/src/server/mcp/tools/google-analytics-tools.ts @@ -257,6 +257,15 @@ function emptyOrganicHint(result: Ga4ReportResult): string { : " This report is limited to Organic Search."; } +function endDateClampNote(result: { + warnings: string[]; + request: { resolvedDateRange: { endDate: string } }; +}) { + return result.warnings.includes("end_date_clamped") + ? ` The requested endDate was moved back to ${result.request.resolvedDateRange.endDate}, the last complete Analytics day.` + : ""; +} + function reportText(label: string, result: Ga4ReportResult) { const range = result.request.resolvedDateRange; const comparison = result.comparison @@ -272,7 +281,7 @@ function reportText(label: string, result: Ga4ReportResult) { const paginate = result.pageInfo.hasMore ? " More rows are available; call again with offset to page through them." : ""; - const summary = `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${comparison}${diagnostics}${limited}${emptyOrganicHint(result)}${paginate}`; + const summary = `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${endDateClampNote(result)}${comparison}${diagnostics}${limited}${emptyOrganicHint(result)}${paginate}`; if (result.rows.length === 0) return summary; return `${summary}\n${formatMcpTable(result.rows, reportTableColumns(result))}`; } @@ -280,7 +289,10 @@ function reportText(label: string, result: Ga4ReportResult) { function overviewText(result: Ga4OverviewResult) { const range = result.request.resolvedDateRange; const previousRange = result.request.previousDateRange; - const summary = `Organic overview for ${range.startDate} through ${range.endDate}, compared with ${previousRange.startDate} through ${previousRange.endDate}.`; + const trendTruncated = result.warnings.includes("trend_truncated") + ? ` The trend was cut at ${result.trend.length} rows; use trend=weekly or a shorter date range for the full series.` + : ""; + const summary = `Organic overview for ${range.startDate} through ${range.endDate}, compared with ${previousRange.startDate} through ${previousRange.endDate}.${endDateClampNote(result)}${trendTruncated}`; if (!result.current) { return `${summary} No Organic Search rows for this date range.`; } diff --git a/src/server/mcp/tools/tool-text-output.test.ts b/src/server/mcp/tools/tool-text-output.test.ts index 6c1a7a5..791b9eb 100644 --- a/src/server/mcp/tools/tool-text-output.test.ts +++ b/src/server/mcp/tools/tool-text-output.test.ts @@ -552,6 +552,21 @@ describe("MCP tool text output (service-backed tools)", () => { ); }); + it("get_google_analytics_organic_landing_pages states an end-date clamp", async () => { + mocks.runGa4Report.mockResolvedValue( + makeGa4ReportResult({ warnings: ["end_date_clamped"] }), + ); + + const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler( + { projectId: "project_1", limit: 100, offset: 0 }, + toolContext, + ); + + expect(textContent(result)).toEqual( + "Organic landing pages: 0 of 0 rows for 2026-07-09 through 2026-08-05. The requested endDate was moved back to 2026-08-05, the last complete Analytics day. This report is limited to Organic Search.", + ); + }); + it("get_google_analytics_traffic_acquisition does not mention Organic Search when empty", async () => { mocks.runGa4Report.mockResolvedValue( makeGa4ReportResult({ @@ -587,6 +602,7 @@ describe("MCP tool text output (service-backed tools)", () => { resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" }, previousDateRange: { startDate: "2026-06-11", endDate: "2026-07-08" }, }, + warnings: [], current: { sessions: 120, activeUsers: 80, @@ -629,13 +645,14 @@ describe("MCP tool text output (service-backed tools)", () => { ); }); - it("get_google_analytics_organic_overview names Organic Search when there is no current row", async () => { + it("get_google_analytics_organic_overview states a truncated trend and names Organic Search when there is no current row", async () => { mocks.getOrganicOverview.mockResolvedValue({ status: "ok", request: { resolvedDateRange: { startDate: "2026-07-09", endDate: "2026-08-05" }, previousDateRange: { startDate: "2026-06-11", endDate: "2026-07-08" }, }, + warnings: ["trend_truncated"], current: null, previous: null, comparison: {}, @@ -648,7 +665,7 @@ describe("MCP tool text output (service-backed tools)", () => { ); expect(textContent(result)).toEqual( - "Organic overview for 2026-07-09 through 2026-08-05, compared with 2026-06-11 through 2026-07-08. No Organic Search rows for this date range.", + "Organic overview for 2026-07-09 through 2026-08-05, compared with 2026-06-11 through 2026-07-08. The trend was cut at 0 rows; use trend=weekly or a shorter date range for the full series. No Organic Search rows for this date range.", ); }); });