From ddea16be38bc6503ec17c24082575ec96654cad5 Mon Sep 17 00:00:00 2001 From: yuriassuncx Date: Wed, 24 Jun 2026 17:49:18 -0300 Subject: [PATCH 1/3] fix(google-analytics): fix input validation and align output schemas with other Google MCPs - Add `fromJson` preprocessor (z.preprocess) to handle MCP clients that serialize arrays and objects as JSON strings instead of native types. Fixes validation errors for dateRanges, dimensions, metrics, dimensionFilter, metricFilter, orderBys, limit, offset, and funnelSteps. - Remove the `response` wrapper from all output schemas so tools return data directly (e.g. `{ rows: [...] }` instead of `{ response: { rows: [...] } }`), consistent with google-search-console and google-tag-manager patterns. - Flatten `getCustomDimensionsAndMetrics` output from `{ dimensions: { customDimensions: [...] }, metrics: { customMetrics: [...] } }` to `{ customDimensions: [...], customMetrics: [...] }`. - Fix code formatting: remove unnecessary blank lines between chained Zod method calls across all tool files. Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/lib/schemas.ts | 87 +------ google-analytics/server/tools/accounts.ts | 7 +- google-analytics/server/tools/ads.ts | 8 +- google-analytics/server/tools/annotations.ts | 8 +- google-analytics/server/tools/properties.ts | 22 +- google-analytics/server/tools/reports.ts | 233 +++++-------------- 6 files changed, 87 insertions(+), 278 deletions(-) diff --git a/google-analytics/server/lib/schemas.ts b/google-analytics/server/lib/schemas.ts index cb336b72..746f4576 100644 --- a/google-analytics/server/lib/schemas.ts +++ b/google-analytics/server/lib/schemas.ts @@ -10,10 +10,8 @@ import { z } from "zod"; // ── Shared primitives ──────────────────────────────────────────────────────── -/** ISO-8601 timestamp string (e.g. "2024-01-15T10:30:00Z"). */ const Timestamp = z.string(); -/** Represents a date used in annotation. */ const DateSchema = z.object({ year: z.number().int().optional(), month: z.number().int().optional(), @@ -23,36 +21,27 @@ const DateSchema = z.object({ // ── Admin API — Account Summaries ──────────────────────────────────────────── export const PropertySummarySchema = z.object({ - /** Resource name, e.g. "properties/123456". */ property: z.string(), displayName: z.string(), - /** e.g. "PROPERTY_TYPE_ORDINARY" | "PROPERTY_TYPE_ROLLUP" | "PROPERTY_TYPE_SUBPROPERTY" */ propertyType: z.string().optional(), - /** Parent resource name, e.g. "accounts/123456". */ parent: z.string().optional(), }); export const AccountSummarySchema = z.object({ - /** Resource name, e.g. "accountSummaries/123456". */ name: z.string(), - /** Account resource name, e.g. "accounts/123456". */ account: z.string().optional(), displayName: z.string().optional(), propertySummaries: z.array(PropertySummarySchema).optional(), }); -export const AccountSummariesResponseSchema = z.object({ - response: z.object({ - accountSummaries: z.array(AccountSummarySchema).optional(), - }), +export const AccountSummariesOutputSchema = z.object({ + accountSummaries: z.array(AccountSummarySchema).optional(), }); // ── Admin API — Property ───────────────────────────────────────────────────── export const PropertySchema = z.object({ - /** Resource name, e.g. "properties/123456". */ name: z.string().optional(), - /** Parent resource, e.g. "accounts/123456" or "properties/123" for sub-properties. */ parent: z.string().optional(), createTime: Timestamp.optional(), updateTime: Timestamp.optional(), @@ -60,68 +49,43 @@ export const PropertySchema = z.object({ industryCategory: z.string().optional(), timeZone: z.string().optional(), currencyCode: z.string().optional(), - /** e.g. "GOOGLE_ANALYTICS_STANDARD" | "GOOGLE_ANALYTICS_360" */ serviceLevel: z.string().optional(), - /** e.g. "PROPERTY_TYPE_ORDINARY" | "PROPERTY_TYPE_ROLLUP" | "PROPERTY_TYPE_SUBPROPERTY" */ propertyType: z.string().optional(), account: z.string().optional(), deleteTime: Timestamp.optional(), expireTime: Timestamp.optional(), }); -export const PropertyResponseSchema = z.object({ - response: PropertySchema, -}); - // ── Admin API — Custom Dimensions & Metrics ────────────────────────────────── export const CustomDimensionSchema = z.object({ - /** Resource name, e.g. "properties/123/customDimensions/456". */ name: z.string().optional(), - /** The parameter name used in events / user properties. */ parameterName: z.string(), displayName: z.string().optional(), description: z.string().optional(), - /** "EVENT" | "USER" | "ITEM" */ scope: z.string().optional(), disallowAdsPersonalization: z.boolean().optional(), }); export const CustomMetricSchema = z.object({ - /** Resource name. */ name: z.string().optional(), parameterName: z.string(), displayName: z.string().optional(), description: z.string().optional(), - /** e.g. "STANDARD" | "CURRENCY" | "FEET" | "METERS" | "KILOMETERS" | "MILES" | "MILLISECONDS" | "SECONDS" | "MINUTES" | "HOURS" */ measurementUnit: z.string().optional(), - /** "EVENT" | "ITEM" */ scope: z.string().optional(), - /** e.g. ["COST_DATA", "REVENUE_DATA"] */ restrictedMetricType: z.array(z.string()).optional(), }); -export const CustomDimensionsListSchema = z.object({ +export const CustomDimensionsAndMetricsOutputSchema = z.object({ customDimensions: z.array(CustomDimensionSchema).optional(), -}); - -export const CustomMetricsListSchema = z.object({ customMetrics: z.array(CustomMetricSchema).optional(), }); -export const CustomDimensionsAndMetricsResponseSchema = z.object({ - /** Result of listCustomDimensions — `{ customDimensions: [...] }` */ - dimensions: CustomDimensionsListSchema, - /** Result of listCustomMetrics — `{ customMetrics: [...] }` */ - metrics: CustomMetricsListSchema, -}); - // ── Admin API — Google Ads Links ───────────────────────────────────────────── export const GoogleAdsLinkSchema = z.object({ - /** Resource name. */ name: z.string().optional(), - /** Google Ads Customer ID (without hyphens). */ customerId: z.string().optional(), canManageClients: z.boolean().optional(), adsPersonalizationEnabled: z.boolean().optional(), @@ -130,29 +94,23 @@ export const GoogleAdsLinkSchema = z.object({ updateTime: Timestamp.optional(), }); -export const GoogleAdsLinksResponseSchema = z.object({ - response: z.object({ - googleAdsLinks: z.array(GoogleAdsLinkSchema).optional(), - }), +export const GoogleAdsLinksOutputSchema = z.object({ + googleAdsLinks: z.array(GoogleAdsLinkSchema).optional(), }); // ── Admin API — Property Annotations ───────────────────────────────────────── export const ReportingDataAnnotationSchema = z.object({ - /** Resource name. */ name: z.string().optional(), annotationDate: DateSchema.optional(), title: z.string().optional(), description: z.string().optional(), systemGenerated: z.boolean().optional(), - /** "RED" | "ORANGE" | "YELLOW" | "GREEN" | "BLUE" | "PURPLE" | "PINK" */ color: z.string().optional(), }); -export const PropertyAnnotationsResponseSchema = z.object({ - response: z.object({ - reportingDataAnnotations: z.array(ReportingDataAnnotationSchema).optional(), - }), +export const PropertyAnnotationsOutputSchema = z.object({ + reportingDataAnnotations: z.array(ReportingDataAnnotationSchema).optional(), }); // ── Data API — shared building blocks ──────────────────────────────────────── @@ -163,7 +121,6 @@ export const DimensionHeaderSchema = z.object({ export const MetricHeaderSchema = z.object({ name: z.string(), - /** e.g. "TYPE_INTEGER" | "TYPE_FLOAT" | "TYPE_SECONDS" | "TYPE_MILLISECONDS" | "TYPE_MINUTES" | "TYPE_HOURS" | "TYPE_STANDARD" | "TYPE_CURRENCY" | "TYPE_FEET" | "TYPE_MILES" | "TYPE_METERS" | "TYPE_KILOMETERS" */ type: z.string().optional(), }); @@ -188,7 +145,6 @@ export const ResponseMetaDataSchema = z.object({ samplingMetadatas: z.array(z.unknown()).optional(), }); -/** Quota state returned when `returnPropertyQuota: true`. */ export const PropertyQuotaSchema = z .object({ tokensPerDay: z @@ -214,7 +170,7 @@ export const PropertyQuotaSchema = z // ── Data API — runReport ────────────────────────────────────────────────────── -export const RunReportResponseSchema = z.object({ +export const RunReportOutputSchema = z.object({ dimensionHeaders: z.array(DimensionHeaderSchema).optional(), metricHeaders: z.array(MetricHeaderSchema).optional(), rows: z.array(RowSchema).optional(), @@ -224,17 +180,12 @@ export const RunReportResponseSchema = z.object({ rowCount: z.number().optional(), metadata: ResponseMetaDataSchema.optional(), propertyQuota: PropertyQuotaSchema.optional(), - /** Always "analyticsData#runReport". */ kind: z.string().optional(), }); -export const RunReportOutputSchema = z.object({ - response: RunReportResponseSchema, -}); - // ── Data API — runRealtimeReport ───────────────────────────────────────────── -export const RunRealtimeReportResponseSchema = z.object({ +export const RunRealtimeReportOutputSchema = z.object({ dimensionHeaders: z.array(DimensionHeaderSchema).optional(), metricHeaders: z.array(MetricHeaderSchema).optional(), rows: z.array(RowSchema).optional(), @@ -243,26 +194,17 @@ export const RunRealtimeReportResponseSchema = z.object({ minimums: z.array(RowSchema).optional(), rowCount: z.number().optional(), propertyQuota: PropertyQuotaSchema.optional(), - /** Always "analyticsData#runRealtimeReport". */ kind: z.string().optional(), }); -export const RunRealtimeReportOutputSchema = z.object({ - response: RunRealtimeReportResponseSchema, -}); - // ── Data API — runFunnelReport ──────────────────────────────────────────────── -export const FunnelResponseMetaDataSchema = z.object({ +const FunnelResponseMetaDataSchema = z.object({ samplingMetadatas: z.array(z.unknown()).optional(), schemaRestrictionResponse: z.unknown().optional(), }); -/** - * Funnel response rows share the same Row structure (dimensionValues + - * metricValues), grouped in funnelTable and funnelVisualization sub-objects. - */ -export const FunnelSubReportSchema = z.object({ +const FunnelSubReportSchema = z.object({ dimensionHeaders: z.array(DimensionHeaderSchema).optional(), metricHeaders: z.array(MetricHeaderSchema).optional(), rows: z.array(RowSchema).optional(), @@ -273,14 +215,9 @@ export const FunnelSubReportSchema = z.object({ metadata: FunnelResponseMetaDataSchema.optional(), }); -export const RunFunnelReportResponseSchema = z.object({ +export const RunFunnelReportOutputSchema = z.object({ funnelTable: FunnelSubReportSchema.optional(), funnelVisualization: FunnelSubReportSchema.optional(), propertyQuota: PropertyQuotaSchema.optional(), - /** Always "analyticsData#runFunnelReport". */ kind: z.string().optional(), }); - -export const RunFunnelReportOutputSchema = z.object({ - response: RunFunnelReportResponseSchema, -}); diff --git a/google-analytics/server/tools/accounts.ts b/google-analytics/server/tools/accounts.ts index 7b0650b3..c283fed1 100644 --- a/google-analytics/server/tools/accounts.ts +++ b/google-analytics/server/tools/accounts.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { createPrivateTool } from "@decocms/runtime/tools"; import type { Env } from "../../shared/deco.gen.ts"; import { GaClient } from "../lib/ga-client.ts"; -import { AccountSummariesResponseSchema } from "../lib/schemas.ts"; +import { AccountSummariesOutputSchema } from "../lib/schemas.ts"; export const getAccountSummariesTool = (env: Env) => createPrivateTool({ @@ -10,13 +10,12 @@ export const getAccountSummariesTool = (env: Env) => description: "Retrieves information about the user's Google Analytics accounts and properties.", inputSchema: z.object({}), - outputSchema: AccountSummariesResponseSchema, + outputSchema: AccountSummariesOutputSchema, execute: async () => { const client = GaClient.fromEnv(env); - try { const result = await client.listAccountSummaries(); - return AccountSummariesResponseSchema.parse({ response: result }); + return AccountSummariesOutputSchema.parse(result); } catch (error) { throw new Error( `Failed to retrieve account summaries: ${error instanceof Error ? error.message : String(error)}`, diff --git a/google-analytics/server/tools/ads.ts b/google-analytics/server/tools/ads.ts index bc10cff3..90b17ece 100644 --- a/google-analytics/server/tools/ads.ts +++ b/google-analytics/server/tools/ads.ts @@ -2,12 +2,10 @@ import { z } from "zod"; import { createPrivateTool } from "@decocms/runtime/tools"; import type { Env } from "../../shared/deco.gen.ts"; import { GaClient } from "../lib/ga-client.ts"; -import { GoogleAdsLinksResponseSchema } from "../lib/schemas.ts"; +import { GoogleAdsLinksOutputSchema } from "../lib/schemas.ts"; const propertySchema = z - .string() - .describe( "GA4 Property identifier — 'properties/1234567' or just '1234567'.", ); @@ -18,12 +16,12 @@ export const listGoogleAdsLinksTool = (env: Env) => description: "Returns a list of links to Google Ads accounts for a GA4 property.", inputSchema: z.object({ property: propertySchema }), - outputSchema: GoogleAdsLinksResponseSchema, + outputSchema: GoogleAdsLinksOutputSchema, execute: async ({ context: args }) => { const client = GaClient.fromEnv(env); try { const result = await client.listGoogleAdsLinks(args.property); - return GoogleAdsLinksResponseSchema.parse({ response: result }); + return GoogleAdsLinksOutputSchema.parse(result); } catch (error) { throw new Error( `Failed to retrieve Google Ads links: ${error instanceof Error ? error.message : String(error)}`, diff --git a/google-analytics/server/tools/annotations.ts b/google-analytics/server/tools/annotations.ts index 81bcce5d..b60b1892 100644 --- a/google-analytics/server/tools/annotations.ts +++ b/google-analytics/server/tools/annotations.ts @@ -2,12 +2,10 @@ import { z } from "zod"; import { createPrivateTool } from "@decocms/runtime/tools"; import type { Env } from "../../shared/deco.gen.ts"; import { GaClient } from "../lib/ga-client.ts"; -import { PropertyAnnotationsResponseSchema } from "../lib/schemas.ts"; +import { PropertyAnnotationsOutputSchema } from "../lib/schemas.ts"; const propertySchema = z - .string() - .describe( "GA4 Property identifier — 'properties/1234567' or just '1234567'.", ); @@ -18,12 +16,12 @@ export const listPropertyAnnotationsTool = (env: Env) => description: "Returns timestamped annotations for a GA4 property — useful for correlating traffic changes with events like campaign launches, site releases, or data collection changes.", inputSchema: z.object({ property: propertySchema }), - outputSchema: PropertyAnnotationsResponseSchema, + outputSchema: PropertyAnnotationsOutputSchema, execute: async ({ context: args }) => { const client = GaClient.fromEnv(env); try { const result = await client.listPropertyAnnotations(args.property); - return PropertyAnnotationsResponseSchema.parse({ response: result }); + return PropertyAnnotationsOutputSchema.parse(result); } catch (error) { throw new Error( `Failed to retrieve property annotations: ${error instanceof Error ? error.message : String(error)}`, diff --git a/google-analytics/server/tools/properties.ts b/google-analytics/server/tools/properties.ts index 8859f14c..e021e1f1 100644 --- a/google-analytics/server/tools/properties.ts +++ b/google-analytics/server/tools/properties.ts @@ -3,14 +3,12 @@ import { createPrivateTool } from "@decocms/runtime/tools"; import type { Env } from "../../shared/deco.gen.ts"; import { GaClient } from "../lib/ga-client.ts"; import { - PropertyResponseSchema, - CustomDimensionsAndMetricsResponseSchema, + PropertySchema, + CustomDimensionsAndMetricsOutputSchema, } from "../lib/schemas.ts"; const propertySchema = z - .string() - .describe( "GA4 Property identifier — 'properties/1234567' or just '1234567'.", ); @@ -21,12 +19,12 @@ export const getPropertyDetailsTool = (env: Env) => description: "Returns metadata and configuration details about a GA4 property.", inputSchema: z.object({ property: propertySchema }), - outputSchema: PropertyResponseSchema, + outputSchema: PropertySchema, execute: async ({ context: args }) => { const client = GaClient.fromEnv(env); try { const result = await client.getProperty(args.property); - return PropertyResponseSchema.parse({ response: result }); + return PropertySchema.parse(result); } catch (error) { throw new Error( `Failed to retrieve property details: ${error instanceof Error ? error.message : String(error)}`, @@ -41,19 +39,17 @@ export const getCustomDimensionsAndMetricsTool = (env: Env) => description: "Retrieves the custom dimensions and custom metrics configured for a GA4 property.", inputSchema: z.object({ property: propertySchema }), - outputSchema: CustomDimensionsAndMetricsResponseSchema, + outputSchema: CustomDimensionsAndMetricsOutputSchema, execute: async ({ context: args }) => { const client = GaClient.fromEnv(env); try { - // Both calls are independent — run in parallel to halve latency. - const [dimensions, metrics] = await Promise.all([ + const [dimensionsResult, metricsResult] = await Promise.all([ client.listCustomDimensions(args.property), client.listCustomMetrics(args.property), ]); - - return CustomDimensionsAndMetricsResponseSchema.parse({ - dimensions, - metrics, + return CustomDimensionsAndMetricsOutputSchema.parse({ + ...(dimensionsResult as object), + ...(metricsResult as object), }); } catch (error) { throw new Error( diff --git a/google-analytics/server/tools/reports.ts b/google-analytics/server/tools/reports.ts index d31cbb0e..19f05977 100644 --- a/google-analytics/server/tools/reports.ts +++ b/google-analytics/server/tools/reports.ts @@ -8,18 +8,26 @@ import { RunRealtimeReportOutputSchema, } from "../lib/schemas.ts"; +// Some MCP clients serialize arrays/objects as JSON strings instead of native +// types. This preprocessor parses the string before Zod validates it. +const fromJson = (schema: T) => + z.preprocess((val) => { + if (typeof val !== "string") return val; + try { + return JSON.parse(val); + } catch { + return val; + } + }, schema); + const DateRangeSchema = z.object({ startDate: z - .string() - .describe( "Inclusive start date in YYYY-MM-DD format, or relative values: 'today', 'yesterday', 'NdaysAgo'.", ), endDate: z - .string() - .describe( "Inclusive end date in YYYY-MM-DD format, or relative values: 'today', 'yesterday'.", ), @@ -30,17 +38,13 @@ const MetricSchema = z.object({ name: z.string() }); // FilterExpression and OrderBy are passed as raw JSON objects matching the GA4 REST API schema. const FilterExpressionSchema = z - .record(z.string(), z.unknown()) - .describe( "GA4 FilterExpression object. See https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression", ); const OrderBySchema = z - .record(z.string(), z.unknown()) - .describe( "GA4 OrderBy object. See https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/OrderBy", ); @@ -52,89 +56,49 @@ export const runReportTool = (env: Env) => "Runs a Google Analytics 4 report using the Data API. Returns dimensions, metrics, and row data for the specified property and date range.", inputSchema: z.object({ property: z - .string() - .describe( "GA4 Property identifier — 'properties/1234567' or just '1234567'.", ), - dateRanges: z - - .array(DateRangeSchema) - - .min(1) - - .describe("One or more date ranges to include in the report."), - dimensions: z - - .array(DimensionSchema) - + dateRanges: fromJson(z.array(DateRangeSchema).min(1)).describe( + "One or more date ranges to include in the report.", + ), + dimensions: fromJson(z.array(DimensionSchema)) .optional() - .describe( "Dimensions to group results by, e.g. [{ name: 'sessionSource' }].", ), - metrics: z - - .array(MetricSchema) - - .min(1) - - .describe("Metrics to aggregate, e.g. [{ name: 'activeUsers' }]."), - dimensionFilter: FilterExpressionSchema.optional().describe( - "Optional filter to restrict dimension values.", - ), - metricFilter: FilterExpressionSchema.optional().describe( - "Optional filter to restrict metric values.", + metrics: fromJson(z.array(MetricSchema).min(1)).describe( + "Metrics to aggregate, e.g. [{ name: 'activeUsers' }].", ), - orderBys: z - - .array(OrderBySchema) - + dimensionFilter: fromJson(FilterExpressionSchema) + .optional() + .describe("Optional filter to restrict dimension values."), + metricFilter: fromJson(FilterExpressionSchema) + .optional() + .describe("Optional filter to restrict metric values."), + orderBys: fromJson(z.array(OrderBySchema)) .optional() - .describe("Optional ordering for returned rows."), - limit: z - - .number() - - .int() - - .positive() - + limit: fromJson(z.number().int().positive()) .optional() - .describe( "Maximum number of rows to return. Defaults to 10,000; max 250,000.", ), - offset: z - - .number() - - .int() - - .nonnegative() - + offset: fromJson(z.number().int().nonnegative()) .optional() - .describe( "Row offset for pagination (0-based). Use with limit to page through results.", ), currencyCode: z - .string() - .optional() - .describe( "Optional ISO 4217 currency code for revenue metrics, e.g. 'USD'.", ), returnPropertyQuota: z - .boolean() - .optional() - .describe( "If true, includes the current GA4 property quota state in the response.", ), @@ -157,12 +121,10 @@ export const runReportTool = (env: Env) => if (args.offset !== undefined) body.offset = args.offset; if (args.currencyCode !== undefined) body.currencyCode = args.currencyCode; - if (args.returnPropertyQuota !== undefined) { + if (args.returnPropertyQuota !== undefined) body.returnPropertyQuota = args.returnPropertyQuota; - } const response = await client.runReport(args.property, body); - - return RunReportOutputSchema.parse({ response }); + return RunReportOutputSchema.parse(response); } catch (error) { throw new Error( `Failed to run report: ${error instanceof Error ? error.message : String(error)}`, @@ -173,32 +135,20 @@ export const runReportTool = (env: Env) => const FunnelStepSchema = z.object({ name: z - .string() - .describe("Display name for this funnel step (shown in reports)."), - filterExpression: z - - .record(z.string(), z.unknown()) - - .describe( - "Required. Condition that qualifies users for this step. See https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FunnelStep#FunnelFilterExpression", - ), + filterExpression: fromJson(z.record(z.string(), z.unknown())).describe( + "Required. Condition that qualifies users for this step. See https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FunnelStep#FunnelFilterExpression", + ), isDirectlyFollowedBy: z - .boolean() - .optional() - .describe( "If true, this step must immediately follow the previous step (no intervening events). Defaults to false.", ), withinDurationFromPriorStep: z - .string() - .optional() - .describe( "Time window within which this step must occur after the prior step, e.g. '3600s' for 1 hour.", ), @@ -211,74 +161,35 @@ export const runFunnelReportTool = (env: Env) => "Runs a Google Analytics 4 funnel report. Analyzes how users progress through a sequence of steps (e.g. landing page → product page → checkout → purchase). Returns per-step completion counts and drop-off rates.", inputSchema: z.object({ property: z - .string() - .describe( "GA4 Property identifier — 'properties/1234567' or just '1234567'.", ), - funnelSteps: z - - .array(FunnelStepSchema) - - .min(2) - - .describe( - "Ordered list of funnel steps. Each step is a GA4 FunnelStep object with a name and filterExpression.", - ), - dateRanges: z - - .array(DateRangeSchema) - - .min(1) - - .describe("One or more date ranges to include in the report."), - funnelBreakdown: z - - .record(z.string(), z.unknown()) - + funnelSteps: fromJson(z.array(FunnelStepSchema).min(2)).describe( + "Ordered list of funnel steps. Each step is a GA4 FunnelStep object with a name and filterExpression.", + ), + dateRanges: fromJson(z.array(DateRangeSchema).min(1)).describe( + "One or more date ranges to include in the report.", + ), + funnelBreakdown: fromJson(z.record(z.string(), z.unknown())) .optional() - .describe( "Optional FunnelBreakdown — adds a sub-dimension to the funnel table rows.", ), - funnelNextAction: z - - .record(z.string(), z.unknown()) - + funnelNextAction: fromJson(z.record(z.string(), z.unknown())) .optional() - .describe( "Optional FunnelNextAction — adds a next-action dimension showing what users do after abandoning a step.", ), - limit: z - - .number() - - .int() - - .positive() - + limit: fromJson(z.number().int().positive()) .optional() - .describe("Maximum number of rows to return."), - offset: z - - .number() - - .int() - - .nonnegative() - + offset: fromJson(z.number().int().nonnegative()) .optional() - .describe("Row offset for pagination."), returnPropertyQuota: z - .boolean() - .optional() - .describe("If true, includes current GA4 property quota in response."), }), outputSchema: RunFunnelReportOutputSchema, @@ -298,8 +209,7 @@ export const runFunnelReportTool = (env: Env) => if (args.returnPropertyQuota !== undefined) body.returnPropertyQuota = args.returnPropertyQuota; const response = await client.runFunnelReport(args.property, body); - - return RunFunnelReportOutputSchema.parse({ response }); + return RunFunnelReportOutputSchema.parse(response); } catch (error) { throw new Error( `Failed to run funnel report: ${error instanceof Error ? error.message : String(error)}`, @@ -315,61 +225,34 @@ export const runRealtimeReportTool = (env: Env) => "Runs a Google Analytics 4 realtime report. Returns live data from the last 30 minutes.", inputSchema: z.object({ property: z - .string() - .describe( "GA4 Property identifier — 'properties/1234567' or just '1234567'.", ), - dimensions: z - - .array(DimensionSchema) - + dimensions: fromJson(z.array(DimensionSchema)) .optional() - .describe("Dimensions to group results by."), - metrics: z.array(MetricSchema).min(1).describe("Metrics to aggregate."), - dimensionFilter: FilterExpressionSchema.optional().describe( - "Optional filter to restrict dimension values.", + metrics: fromJson(z.array(MetricSchema).min(1)).describe( + "Metrics to aggregate.", ), - metricFilter: FilterExpressionSchema.optional().describe( - "Optional filter to restrict metric values.", - ), - orderBys: z - - .array(OrderBySchema) - + dimensionFilter: fromJson(FilterExpressionSchema) + .optional() + .describe("Optional filter to restrict dimension values."), + metricFilter: fromJson(FilterExpressionSchema) + .optional() + .describe("Optional filter to restrict metric values."), + orderBys: fromJson(z.array(OrderBySchema)) .optional() - .describe("Optional ordering for returned rows."), - limit: z - - .number() - - .int() - - .positive() - + limit: fromJson(z.number().int().positive()) .optional() - .describe("Maximum number of rows to return."), - offset: z - - .number() - - .int() - - .nonnegative() - + offset: fromJson(z.number().int().nonnegative()) .optional() - .describe("Row offset for pagination."), returnPropertyQuota: z - .boolean() - .optional() - .describe("If true, includes quota state in the response."), }), outputSchema: RunRealtimeReportOutputSchema, @@ -387,12 +270,10 @@ export const runRealtimeReportTool = (env: Env) => if (args.orderBys !== undefined) body.orderBys = args.orderBys; if (args.limit !== undefined) body.limit = args.limit; if (args.offset !== undefined) body.offset = args.offset; - if (args.returnPropertyQuota !== undefined) { + if (args.returnPropertyQuota !== undefined) body.returnPropertyQuota = args.returnPropertyQuota; - } const response = await client.runRealtimeReport(args.property, body); - - return RunRealtimeReportOutputSchema.parse({ response }); + return RunRealtimeReportOutputSchema.parse(response); } catch (error) { throw new Error( `Failed to run realtime report: ${error instanceof Error ? error.message : String(error)}`, From bffd11eca6a3c1618cda07a67b2f57c957ec089a Mon Sep 17 00:00:00 2001 From: yuriassuncx Date: Wed, 24 Jun 2026 18:08:20 -0300 Subject: [PATCH 2/3] fix(google-analytics): wrap boolean fields with fromJson and handle null strings - Apply fromJson() to returnPropertyQuota (all 3 report tools) and FunnelStepSchema.isDirectlyFollowedBy so MCP clients that serialize booleans as JSON strings (e.g. "true") pass validation, consistent with the existing fix for arrays, objects, and numbers. - In fromJson, treat JSON-parsed null as undefined so optional fields that receive the string "null" degrade gracefully instead of failing Zod validation. Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/tools/reports.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/google-analytics/server/tools/reports.ts b/google-analytics/server/tools/reports.ts index 19f05977..158dabf5 100644 --- a/google-analytics/server/tools/reports.ts +++ b/google-analytics/server/tools/reports.ts @@ -14,7 +14,8 @@ const fromJson = (schema: T) => z.preprocess((val) => { if (typeof val !== "string") return val; try { - return JSON.parse(val); + const parsed = JSON.parse(val); + return parsed === null ? undefined : parsed; } catch { return val; } @@ -96,8 +97,7 @@ export const runReportTool = (env: Env) => .describe( "Optional ISO 4217 currency code for revenue metrics, e.g. 'USD'.", ), - returnPropertyQuota: z - .boolean() + returnPropertyQuota: fromJson(z.boolean()) .optional() .describe( "If true, includes the current GA4 property quota state in the response.", @@ -140,8 +140,7 @@ const FunnelStepSchema = z.object({ filterExpression: fromJson(z.record(z.string(), z.unknown())).describe( "Required. Condition that qualifies users for this step. See https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FunnelStep#FunnelFilterExpression", ), - isDirectlyFollowedBy: z - .boolean() + isDirectlyFollowedBy: fromJson(z.boolean()) .optional() .describe( "If true, this step must immediately follow the previous step (no intervening events). Defaults to false.", @@ -187,8 +186,7 @@ export const runFunnelReportTool = (env: Env) => offset: fromJson(z.number().int().nonnegative()) .optional() .describe("Row offset for pagination."), - returnPropertyQuota: z - .boolean() + returnPropertyQuota: fromJson(z.boolean()) .optional() .describe("If true, includes current GA4 property quota in response."), }), @@ -250,8 +248,7 @@ export const runRealtimeReportTool = (env: Env) => offset: fromJson(z.number().int().nonnegative()) .optional() .describe("Row offset for pagination."), - returnPropertyQuota: z - .boolean() + returnPropertyQuota: fromJson(z.boolean()) .optional() .describe("If true, includes quota state in the response."), }), From fd42de05a2e980170400d23e747347b0bba59153 Mon Sep 17 00:00:00 2001 From: yuriassuncx Date: Wed, 24 Jun 2026 18:12:47 -0300 Subject: [PATCH 3/3] fix(google-analytics): coerce native null to undefined in fromJson preprocessor MCP clients that send null explicitly for absent optional fields (e.g. {"dimensionFilter": null}) were getting a hard Zod type error instead of the field being treated as absent. Add an explicit null check so both native null and the JSON string "null" are treated as undefined. Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/tools/reports.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/google-analytics/server/tools/reports.ts b/google-analytics/server/tools/reports.ts index 158dabf5..5c98b135 100644 --- a/google-analytics/server/tools/reports.ts +++ b/google-analytics/server/tools/reports.ts @@ -12,6 +12,7 @@ import { // types. This preprocessor parses the string before Zod validates it. const fromJson = (schema: T) => z.preprocess((val) => { + if (val === null) return undefined; if (typeof val !== "string") return val; try { const parsed = JSON.parse(val);