fix: show GA4 MCP rows in Claude text output (#553)

This commit is contained in:
Ben Senescu 2026-08-27 18:02:13 -04:00 committed by GitHub
parent ea162a4391
commit 749b38118d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 308 additions and 6 deletions

View File

@ -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<ReturnType<typeof Ga4ReportingService.runReport>>,
) {
type Ga4ReportRow = Ga4ReportResult["rows"][number];
type Ga4OverviewResult = Awaited<
ReturnType<typeof Ga4OrganicOverviewService.getOrganicOverview>
>;
// 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<Ga4ReportRow>[] {
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<TArgs extends ProjectArgs>(
@ -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,
});

View File

@ -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.",
);
});
});