From 749b38118dcbbe95ad2c5a9510dc6ba24451b745 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:02:13 -0400 Subject: [PATCH] fix: show GA4 MCP rows in Claude text output (#553) --- .../mcp/tools/google-analytics-tools.ts | 78 +++++- src/server/mcp/tools/tool-text-output.test.ts | 236 ++++++++++++++++++ 2 files changed, 308 insertions(+), 6 deletions(-) diff --git a/src/server/mcp/tools/google-analytics-tools.ts b/src/server/mcp/tools/google-analytics-tools.ts index 3951ac7..fda37e8 100644 --- a/src/server/mcp/tools/google-analytics-tools.ts +++ b/src/server/mcp/tools/google-analytics-tools.ts @@ -2,6 +2,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService"; +import { OVERVIEW_METRICS } from "@/server/features/ga4/services/Ga4ReportDefinitions"; import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService"; import { GscApiError, @@ -11,6 +12,7 @@ import { import { Ga4ReportingService, type Ga4ReportInput, + type Ga4ReportResult, } from "@/server/features/ga4/services/Ga4ReportingService"; import { Ga4ReportError } from "@/server/lib/ga4Errors"; import { SearchOpportunityService } from "@/server/features/ga4/services/SearchOpportunityService"; @@ -19,6 +21,7 @@ import { mcpResponse } from "@/server/mcp/formatters"; import { looseObjectOutputSchema } from "@/server/mcp/output-schemas"; import { withMcpProjectAuth } from "@/server/mcp/project-auth"; import { projectIdSchema } from "@/server/mcp/schemas"; +import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table"; import { buildDashboardUrl } from "@/server/mcp/urls"; const dateSchema = z @@ -215,10 +218,46 @@ function errorResponse( }); } -function reportText( - label: string, - result: Awaited>, -) { +type Ga4ReportRow = Ga4ReportResult["rows"][number]; +type Ga4OverviewResult = Awaited< + ReturnType +>; + +// These four MCP tools accept channel=all. landing_pages is Organic Search +// only and has no channel argument. +const CHANNEL_SELECTABLE_REPORTS = new Set< + Ga4ReportResult["request"]["reportKind"] +>([ + "page_performance", + "key_events", + "ecommerce_performance", + "audience_breakdown", +]); + +function reportTableColumns( + result: Ga4ReportResult, +): McpTableColumn[] { + return [...result.request.dimensions, ...result.request.metrics].map( + (key) => ({ + header: key, + value: (row) => row[key], + }), + ); +} + +function emptyOrganicHint(result: Ga4ReportResult): string { + if ( + result.totalRowCount !== 0 || + result.request.channel !== "organic_search" + ) { + return ""; + } + return CHANNEL_SELECTABLE_REPORTS.has(result.request.reportKind) + ? " This report is filtered to Organic Search. Pass channel=all to include every channel." + : " This report is limited to Organic Search."; +} + +function reportText(label: string, result: Ga4ReportResult) { const range = result.request.resolvedDateRange; const comparison = result.comparison ? ` Previous-period comparison returned ${result.comparison.rows.length} row(s).` @@ -227,7 +266,34 @@ function reportText( result.diagnostics.length > 0 ? ` ${result.diagnostics.length} diagnostic finding(s) are included.` : ""; - return `${label}: ${result.rowCount} of ${result.totalRowCount} rows for ${range.startDate} through ${range.endDate}.${comparison}${diagnostics}${result.reportMetadata.hasLimitedData ? " Google marked this report as limited; inspect reportMetadata." : ""}`; + const limited = result.reportMetadata.hasLimitedData + ? " Google marked this report as limited; inspect reportMetadata." + : ""; + 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}`; + if (result.rows.length === 0) return summary; + return `${summary}\n${formatMcpTable(result.rows, reportTableColumns(result))}`; +} + +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}.`; + if (!result.current) { + return `${summary} No Organic Search rows for this date range.`; + } + const rows = OVERVIEW_METRICS.map((metric) => ({ + metric, + current: result.current[metric] ?? null, + previous: result.previous?.[metric] ?? null, + })); + return `${summary}\n${formatMcpTable(rows, [ + { header: "metric", value: (row) => row.metric }, + { header: "current", value: (row) => row.current }, + { header: "previous", value: (row) => row.previous }, + ])}`; } function createAnalyticsReportHandler( @@ -407,7 +473,7 @@ export const getGoogleAnalyticsOrganicOverviewTool = { try { const result = await Ga4OrganicOverviewService.getOrganicOverview(args); return mcpResponse({ - text: `Organic overview for ${result.request.resolvedDateRange.startDate} through ${result.request.resolvedDateRange.endDate}, compared with ${result.request.previousDateRange.startDate} through ${result.request.previousDateRange.endDate}.`, + text: overviewText(result), meta: buildProjectMeta(context, args.projectId), structuredContent: result, }); diff --git a/src/server/mcp/tools/tool-text-output.test.ts b/src/server/mcp/tools/tool-text-output.test.ts index 1971ed9..6c1a7a5 100644 --- a/src/server/mcp/tools/tool-text-output.test.ts +++ b/src/server/mcp/tools/tool-text-output.test.ts @@ -1,13 +1,21 @@ +/* eslint-disable max-lines, max-lines-per-function -- one spec covers every service-backed MCP text table */ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as researchTools from "./dataforseo-research-tools"; import { getBacklinksOverviewTool } from "./get-backlinks-overview"; import { getBacklinksProfileTool } from "./get-backlinks-profile"; import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestions"; +import { + getGoogleAnalyticsOrganicLandingPagesTool, + getGoogleAnalyticsOrganicOverviewTool, + getGoogleAnalyticsPagePerformanceTool, + getGoogleAnalyticsTrafficAcquisitionTool, +} from "./google-analytics-tools"; import { getRankTrackerTool } from "./get-rank-tracker"; import { getBusinessUpdatesTool } from "./local-seo-tools"; import { getSerpResultsTool } from "./get-serp-results"; import { researchKeywordsTool } from "./research-keywords"; import { makeToolContext, textContent } from "./tool-test-support"; +import { makeGa4ReportResult } from "@/server/features/ga4/services/ga4-test-fixtures"; import type * as backlinksTargetModule from "@/server/lib/dataforseoBacklinksTarget"; // Verifies that each tool renders its actual row data into the text content @@ -29,6 +37,8 @@ const mocks = vi.hoisted(() => ({ getLatestResults: vi.fn(), getTracker: vi.fn(), getConfigs: vi.fn(), + runGa4Report: vi.fn(), + getOrganicOverview: vi.fn(), })); vi.mock("cloudflare:workers", () => ({ env: {} })); @@ -81,6 +91,14 @@ vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({ getConfigs: mocks.getConfigs, }, })); +vi.mock("@/server/features/ga4/services/Ga4ReportingService", () => ({ + Ga4ReportingService: { runReport: mocks.runGa4Report }, +})); +vi.mock("@/server/features/ga4/services/Ga4OrganicOverviewService", () => ({ + Ga4OrganicOverviewService: { + getOrganicOverview: mocks.getOrganicOverview, + }, +})); const toolContext = makeToolContext(); @@ -415,4 +433,222 @@ describe("MCP tool text output (service-backed tools)", () => { // Rows are trimmed to the depth that was crawled, not the fixed top 20. expect(textContent(result)).toContain('"seo tools" (30 results)'); }); + + it("get_google_analytics_organic_landing_pages renders report rows in the text table", async () => { + mocks.runGa4Report.mockResolvedValue( + makeGa4ReportResult({ + rowCount: 2, + totalRowCount: 2, + rows: [ + { + hostName: "example.com", + landingPage: "/home", + sessions: 12, + activeUsers: 9, + }, + { + hostName: "example.com", + landingPage: "/blog", + sessions: 4, + activeUsers: 3, + }, + ], + request: { + dimensions: ["hostName", "landingPage"], + metrics: ["sessions", "activeUsers"], + }, + }), + ); + + const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler( + { projectId: "project_1", limit: 100, offset: 0 }, + toolContext, + ); + + expect(textContent(result)).toEqual( + [ + "Organic landing pages: 2 of 2 rows for 2026-07-09 through 2026-08-05.", + "hostName | landingPage | sessions | activeUsers", + "example.com | /home | 12 | 9", + "example.com | /blog | 4 | 3", + ].join("\n"), + ); + }); + + it("get_google_analytics_organic_landing_pages renders every fetched row and points at offset paging", async () => { + const rows = Array.from({ length: 16 }, (_, index) => ({ + hostName: "example.com", + landingPage: `/p/${index + 1}`, + sessions: 16 - index, + })); + mocks.runGa4Report.mockResolvedValue( + makeGa4ReportResult({ + rowCount: 16, + totalRowCount: 40, + rows, + pageInfo: { offset: 0, limit: 16, hasMore: true, nextOffset: 16 }, + request: { + dimensions: ["hostName", "landingPage"], + metrics: ["sessions"], + }, + }), + ); + + const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler( + { projectId: "project_1", limit: 16, offset: 0 }, + toolContext, + ); + + expect(textContent(result)).toEqual( + [ + "Organic landing pages: 16 of 40 rows for 2026-07-09 through 2026-08-05. More rows are available; call again with offset to page through them.", + "hostName | landingPage | sessions", + ...rows.map( + (row) => `${row.hostName} | ${row.landingPage} | ${row.sessions}`, + ), + ].join("\n"), + ); + expect(result.structuredContent).toMatchObject({ rows }); + }); + + it("get_google_analytics_page_performance names the Organic Search filter when empty", async () => { + mocks.runGa4Report.mockResolvedValue( + makeGa4ReportResult({ + request: { + reportKind: "page_performance", + channel: "organic_search", + dimensions: ["hostName", "pagePath"], + metrics: ["screenPageViews"], + }, + }), + ); + + const result = await getGoogleAnalyticsPagePerformanceTool.handler( + { + projectId: "project_1", + includeDate: false, + channel: "organic_search", + limit: 100, + offset: 0, + }, + toolContext, + ); + + expect(textContent(result)).toEqual( + "Page performance: 0 of 0 rows for 2026-07-09 through 2026-08-05. This report is filtered to Organic Search. Pass channel=all to include every channel.", + ); + }); + + it("get_google_analytics_organic_landing_pages names Organic Search without a channel argument", async () => { + mocks.runGa4Report.mockResolvedValue(makeGa4ReportResult()); + + 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. 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({ + request: { + reportKind: "traffic_acquisition", + channel: "all", + dimensions: ["sessionDefaultChannelGroup"], + metrics: ["sessions"], + }, + }), + ); + + const result = await getGoogleAnalyticsTrafficAcquisitionTool.handler( + { + projectId: "project_1", + breakdown: "channel_group", + comparePreviousPeriod: false, + limit: 100, + offset: 0, + }, + toolContext, + ); + + expect(textContent(result)).toEqual( + "Traffic acquisition: 0 of 0 rows for 2026-07-09 through 2026-08-05.", + ); + }); + + it("get_google_analytics_organic_overview renders current and previous totals", 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" }, + }, + current: { + sessions: 120, + activeUsers: 80, + engagedSessions: 70, + engagementRate: 0.58, + keyEvents: 9, + transactions: 2, + purchaseRevenue: 40.5, + }, + previous: { + sessions: 100, + activeUsers: 70, + engagedSessions: 60, + engagementRate: 0.5, + keyEvents: 8, + transactions: 1, + purchaseRevenue: 20, + }, + comparison: {}, + trend: [{ date: "20260709", sessions: 5 }], + }); + + const result = await getGoogleAnalyticsOrganicOverviewTool.handler( + { projectId: "project_1", trend: "daily" }, + toolContext, + ); + + expect(textContent(result)).toEqual( + [ + "Organic overview for 2026-07-09 through 2026-08-05, compared with 2026-06-11 through 2026-07-08.", + "metric | current | previous", + "sessions | 120 | 100", + "activeUsers | 80 | 70", + "engagedSessions | 70 | 60", + "engagementRate | 0.58 | 0.50", + "keyEvents | 9 | 8", + "transactions | 2 | 1", + "purchaseRevenue | 40.50 | 20", + ].join("\n"), + ); + }); + + it("get_google_analytics_organic_overview 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" }, + }, + current: null, + previous: null, + comparison: {}, + trend: [], + }); + + const result = await getGoogleAnalyticsOrganicOverviewTool.handler( + { projectId: "project_1", trend: "daily" }, + toolContext, + ); + + 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.", + ); + }); });