fix: show GA4 MCP rows in Claude text output (#553)
This commit is contained in:
parent
ea162a4391
commit
749b38118d
@ -2,6 +2,7 @@
|
|||||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService";
|
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 { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";
|
||||||
import {
|
import {
|
||||||
GscApiError,
|
GscApiError,
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Ga4ReportingService,
|
Ga4ReportingService,
|
||||||
type Ga4ReportInput,
|
type Ga4ReportInput,
|
||||||
|
type Ga4ReportResult,
|
||||||
} from "@/server/features/ga4/services/Ga4ReportingService";
|
} from "@/server/features/ga4/services/Ga4ReportingService";
|
||||||
import { Ga4ReportError } from "@/server/lib/ga4Errors";
|
import { Ga4ReportError } from "@/server/lib/ga4Errors";
|
||||||
import { SearchOpportunityService } from "@/server/features/ga4/services/SearchOpportunityService";
|
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 { looseObjectOutputSchema } from "@/server/mcp/output-schemas";
|
||||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||||
import { projectIdSchema } from "@/server/mcp/schemas";
|
import { projectIdSchema } from "@/server/mcp/schemas";
|
||||||
|
import { formatMcpTable, type McpTableColumn } from "@/server/mcp/table";
|
||||||
import { buildDashboardUrl } from "@/server/mcp/urls";
|
import { buildDashboardUrl } from "@/server/mcp/urls";
|
||||||
|
|
||||||
const dateSchema = z
|
const dateSchema = z
|
||||||
@ -215,10 +218,46 @@ function errorResponse(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function reportText(
|
type Ga4ReportRow = Ga4ReportResult["rows"][number];
|
||||||
label: string,
|
type Ga4OverviewResult = Awaited<
|
||||||
result: Awaited<ReturnType<typeof Ga4ReportingService.runReport>>,
|
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 range = result.request.resolvedDateRange;
|
||||||
const comparison = result.comparison
|
const comparison = result.comparison
|
||||||
? ` Previous-period comparison returned ${result.comparison.rows.length} row(s).`
|
? ` Previous-period comparison returned ${result.comparison.rows.length} row(s).`
|
||||||
@ -227,7 +266,34 @@ function reportText(
|
|||||||
result.diagnostics.length > 0
|
result.diagnostics.length > 0
|
||||||
? ` ${result.diagnostics.length} diagnostic finding(s) are included.`
|
? ` ${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>(
|
function createAnalyticsReportHandler<TArgs extends ProjectArgs>(
|
||||||
@ -407,7 +473,7 @@ export const getGoogleAnalyticsOrganicOverviewTool = {
|
|||||||
try {
|
try {
|
||||||
const result = await Ga4OrganicOverviewService.getOrganicOverview(args);
|
const result = await Ga4OrganicOverviewService.getOrganicOverview(args);
|
||||||
return mcpResponse({
|
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),
|
meta: buildProjectMeta(context, args.projectId),
|
||||||
structuredContent: result,
|
structuredContent: result,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as researchTools from "./dataforseo-research-tools";
|
import * as researchTools from "./dataforseo-research-tools";
|
||||||
import { getBacklinksOverviewTool } from "./get-backlinks-overview";
|
import { getBacklinksOverviewTool } from "./get-backlinks-overview";
|
||||||
import { getBacklinksProfileTool } from "./get-backlinks-profile";
|
import { getBacklinksProfileTool } from "./get-backlinks-profile";
|
||||||
import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestions";
|
import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestions";
|
||||||
|
import {
|
||||||
|
getGoogleAnalyticsOrganicLandingPagesTool,
|
||||||
|
getGoogleAnalyticsOrganicOverviewTool,
|
||||||
|
getGoogleAnalyticsPagePerformanceTool,
|
||||||
|
getGoogleAnalyticsTrafficAcquisitionTool,
|
||||||
|
} from "./google-analytics-tools";
|
||||||
import { getRankTrackerTool } from "./get-rank-tracker";
|
import { getRankTrackerTool } from "./get-rank-tracker";
|
||||||
import { getBusinessUpdatesTool } from "./local-seo-tools";
|
import { getBusinessUpdatesTool } from "./local-seo-tools";
|
||||||
import { getSerpResultsTool } from "./get-serp-results";
|
import { getSerpResultsTool } from "./get-serp-results";
|
||||||
import { researchKeywordsTool } from "./research-keywords";
|
import { researchKeywordsTool } from "./research-keywords";
|
||||||
import { makeToolContext, textContent } from "./tool-test-support";
|
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";
|
import type * as backlinksTargetModule from "@/server/lib/dataforseoBacklinksTarget";
|
||||||
|
|
||||||
// Verifies that each tool renders its actual row data into the text content
|
// Verifies that each tool renders its actual row data into the text content
|
||||||
@ -29,6 +37,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getLatestResults: vi.fn(),
|
getLatestResults: vi.fn(),
|
||||||
getTracker: vi.fn(),
|
getTracker: vi.fn(),
|
||||||
getConfigs: vi.fn(),
|
getConfigs: vi.fn(),
|
||||||
|
runGa4Report: vi.fn(),
|
||||||
|
getOrganicOverview: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
||||||
@ -81,6 +91,14 @@ vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({
|
|||||||
getConfigs: mocks.getConfigs,
|
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();
|
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.
|
// Rows are trimmed to the depth that was crawled, not the fixed top 20.
|
||||||
expect(textContent(result)).toContain('"seo tools" (30 results)');
|
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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user