@mindstone/mcp-server-google-analytics
Advanced tools
| /** | ||
| * GA4 Data API filter expression schema. | ||
| * | ||
| * dimensionFilter / metricFilter inputs were previously accepted as z.any(), | ||
| * which passed arbitrary values straight into the request body. Filter | ||
| * expressions are now fail-closed validated against the recursive GA4 | ||
| * FilterExpression structure before they are sent. | ||
| */ | ||
| import { z } from 'zod'; | ||
| declare const filterSchema: z.ZodObject<{ | ||
| fieldName: z.ZodString; | ||
| stringFilter: z.ZodOptional<z.ZodObject<{ | ||
| matchType: z.ZodOptional<z.ZodString>; | ||
| value: z.ZodString; | ||
| caseSensitive: z.ZodOptional<z.ZodBoolean>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| value: string; | ||
| matchType?: string | undefined; | ||
| caseSensitive?: boolean | undefined; | ||
| }, { | ||
| value: string; | ||
| matchType?: string | undefined; | ||
| caseSensitive?: boolean | undefined; | ||
| }>>; | ||
| inListFilter: z.ZodOptional<z.ZodObject<{ | ||
| values: z.ZodArray<z.ZodString, "many">; | ||
| caseSensitive: z.ZodOptional<z.ZodBoolean>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| values: string[]; | ||
| caseSensitive?: boolean | undefined; | ||
| }, { | ||
| values: string[]; | ||
| caseSensitive?: boolean | undefined; | ||
| }>>; | ||
| numericFilter: z.ZodOptional<z.ZodObject<{ | ||
| operation: z.ZodOptional<z.ZodString>; | ||
| value: z.ZodObject<{ | ||
| int64Value: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>; | ||
| doubleValue: z.ZodOptional<z.ZodNumber>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }, { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| value: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| operation?: string | undefined; | ||
| }, { | ||
| value: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| operation?: string | undefined; | ||
| }>>; | ||
| betweenFilter: z.ZodOptional<z.ZodObject<{ | ||
| fromValue: z.ZodObject<{ | ||
| int64Value: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>; | ||
| doubleValue: z.ZodOptional<z.ZodNumber>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }, { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }>; | ||
| toValue: z.ZodObject<{ | ||
| int64Value: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>; | ||
| doubleValue: z.ZodOptional<z.ZodNumber>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }, { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| fromValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| toValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| }, { | ||
| fromValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| toValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| }>>; | ||
| emptyFilter: z.ZodOptional<z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>>; | ||
| }, "strict", z.ZodTypeAny, { | ||
| fieldName: string; | ||
| stringFilter?: { | ||
| value: string; | ||
| matchType?: string | undefined; | ||
| caseSensitive?: boolean | undefined; | ||
| } | undefined; | ||
| inListFilter?: { | ||
| values: string[]; | ||
| caseSensitive?: boolean | undefined; | ||
| } | undefined; | ||
| numericFilter?: { | ||
| value: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| operation?: string | undefined; | ||
| } | undefined; | ||
| betweenFilter?: { | ||
| fromValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| toValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| } | undefined; | ||
| emptyFilter?: {} | undefined; | ||
| }, { | ||
| fieldName: string; | ||
| stringFilter?: { | ||
| value: string; | ||
| matchType?: string | undefined; | ||
| caseSensitive?: boolean | undefined; | ||
| } | undefined; | ||
| inListFilter?: { | ||
| values: string[]; | ||
| caseSensitive?: boolean | undefined; | ||
| } | undefined; | ||
| numericFilter?: { | ||
| value: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| operation?: string | undefined; | ||
| } | undefined; | ||
| betweenFilter?: { | ||
| fromValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| toValue: { | ||
| int64Value?: string | number | undefined; | ||
| doubleValue?: number | undefined; | ||
| }; | ||
| } | undefined; | ||
| emptyFilter?: {} | undefined; | ||
| }>; | ||
| export interface FilterExpression { | ||
| andGroup?: { | ||
| expressions: FilterExpression[]; | ||
| }; | ||
| orGroup?: { | ||
| expressions: FilterExpression[]; | ||
| }; | ||
| notExpression?: FilterExpression; | ||
| filter?: z.infer<typeof filterSchema>; | ||
| } | ||
| /** Recursive GA4 FilterExpression — accepts nested and/or/not groups. */ | ||
| export declare const filterExpressionSchema: z.ZodType<FilterExpression>; | ||
| export {}; | ||
| //# sourceMappingURL=filters.d.ts.map |
| /** | ||
| * GA4 Data API filter expression schema. | ||
| * | ||
| * dimensionFilter / metricFilter inputs were previously accepted as z.any(), | ||
| * which passed arbitrary values straight into the request body. Filter | ||
| * expressions are now fail-closed validated against the recursive GA4 | ||
| * FilterExpression structure before they are sent. | ||
| */ | ||
| import { z } from 'zod'; | ||
| const metricValueSchema = z | ||
| .object({ | ||
| int64Value: z.union([z.string(), z.number()]).optional(), | ||
| doubleValue: z.number().optional(), | ||
| }) | ||
| .strict(); | ||
| const filterSchema = z | ||
| .object({ | ||
| fieldName: z.string().min(1), | ||
| stringFilter: z | ||
| .object({ | ||
| matchType: z.string().optional(), | ||
| value: z.string(), | ||
| caseSensitive: z.boolean().optional(), | ||
| }) | ||
| .strict() | ||
| .optional(), | ||
| inListFilter: z | ||
| .object({ | ||
| values: z.array(z.string()).min(1), | ||
| caseSensitive: z.boolean().optional(), | ||
| }) | ||
| .strict() | ||
| .optional(), | ||
| numericFilter: z | ||
| .object({ | ||
| operation: z.string().optional(), | ||
| value: metricValueSchema, | ||
| }) | ||
| .strict() | ||
| .optional(), | ||
| betweenFilter: z | ||
| .object({ | ||
| fromValue: metricValueSchema, | ||
| toValue: metricValueSchema, | ||
| }) | ||
| .strict() | ||
| .optional(), | ||
| emptyFilter: z.object({}).strict().optional(), | ||
| }) | ||
| .strict(); | ||
| /** Recursive GA4 FilterExpression — accepts nested and/or/not groups. */ | ||
| export const filterExpressionSchema = z.lazy(() => z | ||
| .object({ | ||
| andGroup: z | ||
| .object({ expressions: z.array(filterExpressionSchema).min(1) }) | ||
| .strict() | ||
| .optional(), | ||
| orGroup: z | ||
| .object({ expressions: z.array(filterExpressionSchema).min(1) }) | ||
| .strict() | ||
| .optional(), | ||
| notExpression: filterExpressionSchema.optional(), | ||
| filter: filterSchema.optional(), | ||
| }) | ||
| .strict()); | ||
| //# sourceMappingURL=filters.js.map |
| /** | ||
| * Audience export tools — create/get/list/query GA4 audience exports | ||
| * (Data API v1beta, generally available). | ||
| * | ||
| * An audience export is a server-side snapshot of the users in an audience. | ||
| * The workflow is: ga_create_audience_export -> poll ga_get_audience_export | ||
| * until state is ACTIVE -> page rows with ga_query_audience_export. Creating | ||
| * an export charges audience-export quota tokens, so the create tool is | ||
| * annotated non-read-only and destructive (production-impacting, quota- | ||
| * consuming materialisation) even though it does not modify property | ||
| * configuration. | ||
| */ | ||
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerAudienceExportTools(server: McpServer): void; | ||
| //# sourceMappingURL=audience-exports.d.ts.map |
| /** | ||
| * Audience export tools — create/get/list/query GA4 audience exports | ||
| * (Data API v1beta, generally available). | ||
| * | ||
| * An audience export is a server-side snapshot of the users in an audience. | ||
| * The workflow is: ga_create_audience_export -> poll ga_get_audience_export | ||
| * until state is ACTIVE -> page rows with ga_query_audience_export. Creating | ||
| * an export charges audience-export quota tokens, so the create tool is | ||
| * annotated non-read-only and destructive (production-impacting, quota- | ||
| * consuming materialisation) even though it does not modify property | ||
| * configuration. | ||
| */ | ||
| import { z } from 'zod'; | ||
| import { googleApi, paginate, propertyPath, assertResourceIdSegment, Bases } from '../client.js'; | ||
| import { GoogleAnalyticsError } from '../types.js'; | ||
| import { wrapUntrusted } from '../untrusted-content.js'; | ||
| import { compactObject, int64Field, parseApiResponse, UNTRUSTED_SOURCES, withErrorHandling } from '../utils.js'; | ||
| const READ_ONLY = { | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }; | ||
| const CREATE_EXPORT = { | ||
| readOnlyHint: false, | ||
| // Creating an audience export materialises a server-side snapshot and | ||
| // charges audience-export quota tokens — a production-impacting, | ||
| // non-idempotent operation, so it is annotated destructive (invariant #7). | ||
| destructiveHint: true, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| }; | ||
| /** | ||
| * Runtime shape of an audience export resource. Validated at the boundary | ||
| * (fail-closed) instead of only TypeScript-cast; .passthrough() keeps the | ||
| * surface forward-compatible with new vendor fields. | ||
| */ | ||
| const audienceExportSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| audience: z.string().optional(), | ||
| audienceDisplayName: z.string().optional(), | ||
| dimensions: z | ||
| .array(z.object({ dimensionName: z.string().optional() }).passthrough()) | ||
| .optional(), | ||
| state: z.string().optional(), | ||
| beginCreatingTime: z.string().optional(), | ||
| creationQuotaTokensCharged: int64Field.optional(), | ||
| rowCount: int64Field.optional(), | ||
| }) | ||
| .passthrough(); | ||
| /** Runtime shape of the audienceExports:query response. */ | ||
| const audienceExportQuerySchema = z | ||
| .object({ | ||
| audienceRows: z | ||
| .array(z | ||
| .object({ | ||
| dimensionValues: z | ||
| .array(z.object({ value: z.string().optional() }).passthrough()) | ||
| .optional(), | ||
| }) | ||
| .passthrough()) | ||
| .optional(), | ||
| audienceExport: audienceExportSchema.optional(), | ||
| rowCount: int64Field.optional(), | ||
| }) | ||
| .passthrough(); | ||
| /** Resolve `properties/<id>/audienceExports/<exportId>` from flexible input. */ | ||
| function audienceExportPath(propertyId, exportId) { | ||
| const property = propertyPath(propertyId); | ||
| const clean = String(exportId) | ||
| .replace(/^properties\/[^/]+\/audienceExports\//, '') | ||
| .replace(/^audienceExports\//, ''); | ||
| if (!clean) { | ||
| throw new GoogleAnalyticsError('Audience export ID is required.', 'AUDIENCE_EXPORT_ID_REQUIRED', 'Pass `export_id` as the bare export ID or the full resource name returned by ga_create_audience_export.'); | ||
| } | ||
| return `${property}/audienceExports/${assertResourceIdSegment(clean, 'audience export ID')}`; | ||
| } | ||
| /** Resolve `properties/<id>/audiences/<audienceId>` from flexible input. */ | ||
| function audiencePath(propertyId, audienceId) { | ||
| const property = propertyPath(propertyId); | ||
| const clean = String(audienceId) | ||
| .replace(/^properties\/[^/]+\/audiences\//, '') | ||
| .replace(/^audiences\//, ''); | ||
| if (!clean) { | ||
| throw new GoogleAnalyticsError('Audience ID is required.', 'AUDIENCE_ID_REQUIRED', 'Pass `audience` as the bare audience ID or the full resource name. Use ga_list_audiences to discover audience IDs.'); | ||
| } | ||
| return `${property}/audiences/${assertResourceIdSegment(clean, 'audience ID')}`; | ||
| } | ||
| function mapAudienceExport(audienceExport) { | ||
| return { | ||
| name: audienceExport.name || null, | ||
| audience: audienceExport.audience || null, | ||
| audienceDisplayName: wrapUntrusted(audienceExport.audienceDisplayName, UNTRUSTED_SOURCES.audienceExport) || | ||
| null, | ||
| // Vendor-echoed audience dimension names — envelope (invariant #6). | ||
| dimensions: (audienceExport.dimensions || []).map((dim) => wrapUntrusted(dim.dimensionName, UNTRUSTED_SOURCES.audienceExport) || null), | ||
| state: audienceExport.state || null, | ||
| beginCreatingTime: audienceExport.beginCreatingTime || null, | ||
| creationQuotaTokensCharged: audienceExport.creationQuotaTokensCharged ?? null, | ||
| rowCount: audienceExport.rowCount ?? null, | ||
| }; | ||
| } | ||
| const exportIdShape = { | ||
| property_id: z | ||
| .string() | ||
| .optional() | ||
| .describe('Optional GA4 property ID. Defaults to GA4_PROPERTY_ID.'), | ||
| export_id: z | ||
| .string() | ||
| .describe('Audience export ID — bare ID or full resource name (properties/<id>/audienceExports/<exportId>), as returned by ga_create_audience_export.'), | ||
| }; | ||
| const CreateAudienceExportInputShape = { | ||
| property_id: z | ||
| .string() | ||
| .optional() | ||
| .describe('Optional GA4 property ID. Defaults to GA4_PROPERTY_ID.'), | ||
| audience: z | ||
| .string() | ||
| .describe('Audience to export — bare ID or full resource name. Use ga_list_audiences to discover audience IDs.'), | ||
| dimensions: z | ||
| .array(z.string()) | ||
| .optional() | ||
| .describe('Optional audience dimension names (e.g. userId, deviceId, isAdsPersonalizationAllowed). Defaults to the API default set when omitted.'), | ||
| }; | ||
| const QueryAudienceExportInputShape = { | ||
| ...exportIdShape, | ||
| offset: z.number().int().nonnegative().default(0), | ||
| limit: z | ||
| .number() | ||
| .int() | ||
| .positive() | ||
| .max(250_000) | ||
| .default(1_000) | ||
| .describe('Rows per page. The API caps a single page at 250,000 rows.'), | ||
| }; | ||
| export function registerAudienceExportTools(server) { | ||
| server.registerTool('ga_create_audience_export', { | ||
| description: 'Create an audience export — a server-side snapshot of the users in a GA4 audience for later retrieval. Charges audience-export quota tokens and takes seconds-to-minutes to become ACTIVE; poll with ga_get_audience_export, then page users with ga_query_audience_export. Does not modify property configuration.', | ||
| inputSchema: CreateAudienceExportInputShape, | ||
| annotations: CREATE_EXPORT, | ||
| }, withErrorHandling(async (rawArgs) => { | ||
| const args = z.object(CreateAudienceExportInputShape).parse(rawArgs ?? {}); | ||
| const property = propertyPath(args.property_id); | ||
| const response = parseApiResponse(audienceExportSchema, await googleApi(`/${property}/audienceExports`, { | ||
| method: 'POST', | ||
| body: { | ||
| audienceExport: compactObject({ | ||
| audience: audiencePath(args.property_id, args.audience), | ||
| dimensions: args.dimensions?.map((dimensionName) => ({ dimensionName })), | ||
| }), | ||
| }, | ||
| baseUrl: Bases.data, | ||
| }), 'audienceExports.create'); | ||
| return JSON.stringify({ ok: true, property, audienceExport: mapAudienceExport(response) }); | ||
| })); | ||
| server.registerTool('ga_get_audience_export', { | ||
| description: 'Get the configuration metadata and state of an audience export. Poll this after ga_create_audience_export until state is ACTIVE before querying rows.', | ||
| inputSchema: z.object(exportIdShape), | ||
| annotations: READ_ONLY, | ||
| }, withErrorHandling(async (args) => { | ||
| const name = audienceExportPath(args.property_id, args.export_id); | ||
| const response = parseApiResponse(audienceExportSchema, await googleApi(`/${name}`, { baseUrl: Bases.data }), 'audienceExports.get'); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| property: name.split('/audienceExports/')[0], | ||
| audienceExport: mapAudienceExport(response), | ||
| }); | ||
| })); | ||
| server.registerTool('ga_list_audience_exports', { | ||
| description: 'List all audience exports for a GA4 property. Useful to find and reuse an existing export rather than creating a new one.', | ||
| inputSchema: z.object({ | ||
| property_id: z | ||
| .string() | ||
| .optional() | ||
| .describe('Optional GA4 property ID. Defaults to GA4_PROPERTY_ID.'), | ||
| }), | ||
| annotations: READ_ONLY, | ||
| }, withErrorHandling(async (args) => { | ||
| const property = propertyPath(args.property_id); | ||
| const exports = await paginate(`/${property}/audienceExports`, { | ||
| itemKey: 'audienceExports', | ||
| itemSchema: audienceExportSchema, | ||
| query: { pageSize: 200 }, | ||
| baseUrl: Bases.data, | ||
| }); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| property, | ||
| audienceExports: exports.map(mapAudienceExport), | ||
| }); | ||
| })); | ||
| server.registerTool('ga_query_audience_export', { | ||
| description: 'Retrieve users from an ACTIVE audience export, with offset/limit pagination. Rows contain user-level identifiers (user IDs / device IDs) — treat them as privacy-sensitive. The export must be ACTIVE; poll ga_get_audience_export first.', | ||
| inputSchema: QueryAudienceExportInputShape, | ||
| annotations: READ_ONLY, | ||
| }, withErrorHandling(async (rawArgs) => { | ||
| const args = z.object(QueryAudienceExportInputShape).parse(rawArgs ?? {}); | ||
| const name = audienceExportPath(args.property_id, args.export_id); | ||
| const response = parseApiResponse(audienceExportQuerySchema, await googleApi(`/${name}:query`, { | ||
| method: 'POST', | ||
| body: { offset: String(args.offset), limit: String(args.limit) }, | ||
| baseUrl: Bases.data, | ||
| }), 'audienceExports.query'); | ||
| // Vendor-echoed dimension names become structural keys in the row | ||
| // objects — envelope them like the recursive helper does (invariant #6). | ||
| const dimensionNames = (response.audienceExport?.dimensions || []).map((dim) => wrapUntrusted(dim.dimensionName, UNTRUSTED_SOURCES.audienceExport) ?? 'unknown'); | ||
| const rows = (response.audienceRows || []).map((row) => { | ||
| const item = {}; | ||
| dimensionNames.forEach((dimensionName, index) => { | ||
| item[dimensionName] = | ||
| wrapUntrusted(row.dimensionValues?.[index]?.value, UNTRUSTED_SOURCES.audienceExport) ?? null; | ||
| }); | ||
| return item; | ||
| }); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| audienceExport: response.audienceExport | ||
| ? mapAudienceExport(response.audienceExport) | ||
| : null, | ||
| rowCount: response.rowCount ?? rows.length, | ||
| offset: args.offset, | ||
| limit: args.limit, | ||
| rows, | ||
| }); | ||
| })); | ||
| } | ||
| //# sourceMappingURL=audience-exports.js.map |
| /** | ||
| * Report task tools — asynchronous, long-running GA4 reports over the Data | ||
| * API v1alpha `reportTasks` surface (not yet promoted to v1beta; alpha | ||
| * endpoints can change without notice). | ||
| * | ||
| * Report tasks are the sanctioned way to pull large exports without hitting | ||
| * synchronous request timeouts: ga_create_report_task starts the task, | ||
| * ga_get_report_task polls until state is ACTIVE, and ga_query_report_task | ||
| * pages rows with offset/limit (up to 250,000 rows per page). | ||
| */ | ||
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| export declare function registerReportTaskTools(server: McpServer): void; | ||
| //# sourceMappingURL=report-tasks.d.ts.map |
| /** | ||
| * Report task tools — asynchronous, long-running GA4 reports over the Data | ||
| * API v1alpha `reportTasks` surface (not yet promoted to v1beta; alpha | ||
| * endpoints can change without notice). | ||
| * | ||
| * Report tasks are the sanctioned way to pull large exports without hitting | ||
| * synchronous request timeouts: ga_create_report_task starts the task, | ||
| * ga_get_report_task polls until state is ACTIVE, and ga_query_report_task | ||
| * pages rows with offset/limit (up to 250,000 rows per page). | ||
| */ | ||
| import { z } from 'zod'; | ||
| import { googleApi, propertyPath, assertResourceIdSegment, Bases } from '../client.js'; | ||
| import { filterExpressionSchema } from '../filters.js'; | ||
| import { dataApiResponseSchema, GoogleAnalyticsError } from '../types.js'; | ||
| import { wrapUntrusted } from '../untrusted-content.js'; | ||
| import { compactObject, formatRows, int64Field, parseApiResponse, parseOrderBy, toNameList, UNTRUSTED_SOURCES, withErrorHandling, } from '../utils.js'; | ||
| const READ_ONLY = { | ||
| readOnlyHint: true, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }; | ||
| const CREATE_TASK = { | ||
| readOnlyHint: false, | ||
| // Starting a report task materialises up to `limit` rows server-side and | ||
| // charges report-task quota tokens — a production-impacting, non-idempotent | ||
| // operation, so it is annotated destructive (invariant #7). | ||
| destructiveHint: true, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| }; | ||
| /** | ||
| * Runtime shape of a report task resource. Validated at the boundary | ||
| * (fail-closed) instead of only TypeScript-cast; .passthrough() keeps the | ||
| * alpha surface forward-compatible with new vendor fields. | ||
| */ | ||
| const reportTaskSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| reportMetadata: z | ||
| .object({ | ||
| state: z.string().optional(), | ||
| taskRowCount: int64Field.optional(), | ||
| totalRowCount: int64Field.optional(), | ||
| beginCreatingTime: z.string().optional(), | ||
| creationQuotaTokensCharged: int64Field.optional(), | ||
| errorMessage: z.string().optional(), | ||
| }) | ||
| .passthrough() | ||
| .optional(), | ||
| }) | ||
| .passthrough(); | ||
| /** Resolve `properties/<id>/reportTasks/<taskId>` from flexible input. */ | ||
| function reportTaskPath(propertyId, taskId) { | ||
| const property = propertyPath(propertyId); | ||
| const clean = String(taskId) | ||
| .replace(/^properties\/[^/]+\/reportTasks\//, '') | ||
| .replace(/^reportTasks\//, ''); | ||
| if (!clean) { | ||
| throw new GoogleAnalyticsError('Report task ID is required.', 'REPORT_TASK_ID_REQUIRED', 'Pass `task_id` as the bare task ID or the full resource name returned by ga_create_report_task.'); | ||
| } | ||
| return `${property}/reportTasks/${assertResourceIdSegment(clean, 'report task ID')}`; | ||
| } | ||
| function mapReportTask(task) { | ||
| return { | ||
| name: task.name || null, | ||
| state: task.reportMetadata?.state || null, | ||
| taskRowCount: task.reportMetadata?.taskRowCount ?? null, | ||
| totalRowCount: task.reportMetadata?.totalRowCount ?? null, | ||
| beginCreatingTime: task.reportMetadata?.beginCreatingTime || null, | ||
| creationQuotaTokensCharged: task.reportMetadata?.creationQuotaTokensCharged ?? null, | ||
| // Vendor-authored failure detail — envelope before model output | ||
| // (invariant #6). | ||
| errorMessage: wrapUntrusted(task.reportMetadata?.errorMessage, UNTRUSTED_SOURCES.report) || null, | ||
| }; | ||
| } | ||
| const taskIdShape = { | ||
| property_id: z | ||
| .string() | ||
| .optional() | ||
| .describe('Optional GA4 property ID. Defaults to GA4_PROPERTY_ID.'), | ||
| task_id: z | ||
| .string() | ||
| .describe('Report task ID — bare ID or full resource name (properties/<id>/reportTasks/<taskId>), as returned by ga_create_report_task.'), | ||
| }; | ||
| const CreateReportTaskInputShape = { | ||
| property_id: z | ||
| .string() | ||
| .optional() | ||
| .describe('Optional GA4 property ID. Defaults to GA4_PROPERTY_ID.'), | ||
| start_date: z | ||
| .string() | ||
| .default('7daysAgo') | ||
| .describe('Start date, e.g. 7daysAgo or 2026-04-01.'), | ||
| end_date: z.string().default('today').describe('End date, e.g. today or 2026-04-28.'), | ||
| dimensions: z | ||
| .union([z.string(), z.array(z.string())]) | ||
| .optional() | ||
| .describe('Dimension names (comma-separated string or array).'), | ||
| metrics: z | ||
| .union([z.string(), z.array(z.string())]) | ||
| .optional() | ||
| .describe('Metric names (comma-separated string or array).'), | ||
| limit: z | ||
| .number() | ||
| .int() | ||
| .positive() | ||
| .max(1_000_000) | ||
| .default(10_000) | ||
| .describe('Total rows the task materialises from Analytics storage. Page through them with ga_query_report_task.'), | ||
| offset: z.number().int().nonnegative().optional(), | ||
| order_by: z | ||
| .union([z.string(), z.array(z.string())]) | ||
| .optional() | ||
| .describe('Order field, prefix with - for descending.'), | ||
| dimension_filter: filterExpressionSchema | ||
| .optional() | ||
| .describe('GA4 dimensionFilter (FilterExpression object).'), | ||
| metric_filter: filterExpressionSchema | ||
| .optional() | ||
| .describe('GA4 metricFilter (FilterExpression object).'), | ||
| keep_empty_rows: z.boolean().optional(), | ||
| currency_code: z.string().optional(), | ||
| }; | ||
| const QueryReportTaskInputShape = { | ||
| ...taskIdShape, | ||
| offset: z.number().int().nonnegative().default(0), | ||
| limit: z | ||
| .number() | ||
| .int() | ||
| .positive() | ||
| .max(250_000) | ||
| .default(10_000) | ||
| .describe('Rows per page. The API caps a single page at 250,000 rows.'), | ||
| }; | ||
| export function registerReportTaskTools(server) { | ||
| server.registerTool('ga_create_report_task', { | ||
| description: 'Start an asynchronous report task for a large GA4 export. Unlike ga_run_report this has no synchronous timeout and no row-volume warning gate — the task materialises up to `limit` rows server-side. Poll ga_get_report_task until state is ACTIVE, then page rows with ga_query_report_task. Uses the v1alpha Data API; structure may evolve over time.', | ||
| inputSchema: CreateReportTaskInputShape, | ||
| annotations: CREATE_TASK, | ||
| }, withErrorHandling(async (rawArgs) => { | ||
| const args = z.object(CreateReportTaskInputShape).parse(rawArgs ?? {}); | ||
| const dimensions = toNameList(args.dimensions); | ||
| const metrics = toNameList(args.metrics); | ||
| const metricList = metrics.length ? metrics : ['totalUsers', 'sessions']; | ||
| const orderBys = parseOrderBy(args.order_by, dimensions, metricList); | ||
| const property = propertyPath(args.property_id); | ||
| const response = parseApiResponse(reportTaskSchema, await googleApi(`/${property}/reportTasks`, { | ||
| method: 'POST', | ||
| body: { | ||
| reportDefinition: compactObject({ | ||
| dateRanges: [{ startDate: args.start_date, endDate: args.end_date }], | ||
| dimensions: dimensions.map((name) => ({ name })), | ||
| metrics: metricList.map((name) => ({ name })), | ||
| limit: String(args.limit), | ||
| offset: args.offset !== undefined ? String(args.offset) : undefined, | ||
| orderBys: orderBys.length ? orderBys : undefined, | ||
| dimensionFilter: args.dimension_filter, | ||
| metricFilter: args.metric_filter, | ||
| keepEmptyRows: args.keep_empty_rows, | ||
| currencyCode: args.currency_code, | ||
| }), | ||
| }, | ||
| baseUrl: Bases.dataAlpha, | ||
| }), 'reportTasks.create'); | ||
| return JSON.stringify({ ok: true, property, reportTask: mapReportTask(response) }); | ||
| })); | ||
| server.registerTool('ga_get_report_task', { | ||
| description: 'Get the metadata and state of a report task. Poll this after ga_create_report_task until state is ACTIVE before querying rows. Uses the v1alpha Data API.', | ||
| inputSchema: z.object(taskIdShape), | ||
| annotations: READ_ONLY, | ||
| }, withErrorHandling(async (args) => { | ||
| const name = reportTaskPath(args.property_id, args.task_id); | ||
| const response = parseApiResponse(reportTaskSchema, await googleApi(`/${name}`, { baseUrl: Bases.dataAlpha }), 'reportTasks.get'); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| property: name.split('/reportTasks/')[0], | ||
| reportTask: mapReportTask(response), | ||
| }); | ||
| })); | ||
| server.registerTool('ga_query_report_task', { | ||
| description: 'Retrieve rows from an ACTIVE report task with offset/limit pagination (up to 250,000 rows per page). The task must be ACTIVE; poll ga_get_report_task first. Uses the v1alpha Data API.', | ||
| inputSchema: QueryReportTaskInputShape, | ||
| annotations: READ_ONLY, | ||
| }, withErrorHandling(async (rawArgs) => { | ||
| const args = z.object(QueryReportTaskInputShape).parse(rawArgs ?? {}); | ||
| const name = reportTaskPath(args.property_id, args.task_id); | ||
| const response = parseApiResponse(dataApiResponseSchema, await googleApi(`/${name}:query`, { | ||
| method: 'POST', | ||
| body: { offset: String(args.offset), limit: String(args.limit) }, | ||
| baseUrl: Bases.dataAlpha, | ||
| }), 'reportTasks.query'); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| reportTask: name, | ||
| offset: args.offset, | ||
| limit: args.limit, | ||
| ...formatRows(response), | ||
| }); | ||
| })); | ||
| } | ||
| //# sourceMappingURL=report-tasks.js.map |
| /** | ||
| * AGENTS.md security invariant #6 — content fetched from an external system | ||
| * MUST be wrapped in an `<untrusted-content source="…">…</untrusted-content>` | ||
| * envelope (with close-tag breakout escaping) before it is returned to the | ||
| * LLM, so the model treats third-party / attacker-controllable text as DATA, | ||
| * not as instructions. | ||
| * | ||
| * This is a VENDORED copy of the canonical shared reference in | ||
| * `test-harness/src/untrusted-content.ts` — connectors cannot `import` the | ||
| * test-harness at runtime (it is a test/dev-only `file:` dependency that is | ||
| * never published into a connector's `dist/`), so the helper lives in the | ||
| * connector's own runtime source. Keep this byte-for-byte in sync with the | ||
| * shared reference; do NOT weaken the escaping back to a simple `replaceAll` | ||
| * (that family misses whitespace / case close-tag variants like | ||
| * `</untrusted-content >` / `</UNTRUSTED-CONTENT>`). | ||
| * | ||
| * GA4 data that reaches these helpers: report dimension values (page titles, | ||
| * campaign names, custom-dimension values — partly attacker-controllable via | ||
| * collected traffic), custom-dimension metadata uiName/description, and | ||
| * user-authored admin display names / descriptions / definition blobs. | ||
| * Metric values (numeric strings) and resource identifiers are structural | ||
| * and stay unwrapped so agents can compose follow-up calls. | ||
| */ | ||
| /** | ||
| * Wrap a single untrusted string in an `<untrusted-content source="…">` | ||
| * envelope, escaping any embedded close-tag variant so the envelope cannot be | ||
| * broken out of. | ||
| * | ||
| * `undefined` and `null` are passed through untouched so callers can apply the wrapper | ||
| * uniformly to optional fields without branching. | ||
| * | ||
| * Idempotent: when `text` is already a properly-shaped envelope for the SAME | ||
| * `source` (starts with the matching OPEN tag, ends with CLOSE, and contains no | ||
| * internal close-tag variants), the original string is returned unchanged so | ||
| * `wrapUntrusted(wrapUntrusted(s, src), src) === wrapUntrusted(s, src)`. | ||
| */ | ||
| export declare function wrapUntrusted(text: string | null | undefined, source: string): string | undefined; | ||
| /** | ||
| * Strip one `<untrusted-content>` envelope from `text` if present, returning raw | ||
| * strings unchanged. This is intentionally one-layer and idempotent for already | ||
| * raw input so callers can accept either displayed wrapped content or manually | ||
| * authored content. | ||
| */ | ||
| export declare function unwrapUntrusted(text: string): string; | ||
| /** | ||
| * Recursively wrap every string key and value reachable inside `value` (strings, | ||
| * arrays, plain-object property keys and values) in an `<untrusted-content>` envelope. | ||
| * | ||
| * Use this when a whole response blob is third-party data and you want to | ||
| * envelope it wholesale rather than enumerate fields. Non-string leaves (numbers, | ||
| * booleans, null) pass through unchanged. | ||
| */ | ||
| export declare function wrapUntrustedJsonStrings<T>(value: T, source: string): T; | ||
| /** | ||
| * Recursively unwrap every string key and value reachable inside `value`. | ||
| * Non-string leaves pass through unchanged. | ||
| */ | ||
| export declare function unwrapUntrustedJsonStrings<T>(value: T): T; | ||
| //# sourceMappingURL=untrusted-content.d.ts.map |
| /** | ||
| * AGENTS.md security invariant #6 — content fetched from an external system | ||
| * MUST be wrapped in an `<untrusted-content source="…">…</untrusted-content>` | ||
| * envelope (with close-tag breakout escaping) before it is returned to the | ||
| * LLM, so the model treats third-party / attacker-controllable text as DATA, | ||
| * not as instructions. | ||
| * | ||
| * This is a VENDORED copy of the canonical shared reference in | ||
| * `test-harness/src/untrusted-content.ts` — connectors cannot `import` the | ||
| * test-harness at runtime (it is a test/dev-only `file:` dependency that is | ||
| * never published into a connector's `dist/`), so the helper lives in the | ||
| * connector's own runtime source. Keep this byte-for-byte in sync with the | ||
| * shared reference; do NOT weaken the escaping back to a simple `replaceAll` | ||
| * (that family misses whitespace / case close-tag variants like | ||
| * `</untrusted-content >` / `</UNTRUSTED-CONTENT>`). | ||
| * | ||
| * GA4 data that reaches these helpers: report dimension values (page titles, | ||
| * campaign names, custom-dimension values — partly attacker-controllable via | ||
| * collected traffic), custom-dimension metadata uiName/description, and | ||
| * user-authored admin display names / descriptions / definition blobs. | ||
| * Metric values (numeric strings) and resource identifiers are structural | ||
| * and stay unwrapped so agents can compose follow-up calls. | ||
| */ | ||
| const UNTRUSTED_CLOSE_TAG_VARIANT = /<\/untrusted-content\s*>/gi; | ||
| const ESCAPED_UNTRUSTED_CLOSE_TAG = '<\\/untrusted-content>'; | ||
| const UNTRUSTED_ENVELOPE = /^<untrusted-content source="[^"]*">([\s\S]*)<\/untrusted-content>$/; | ||
| function escapeAttr(s) { | ||
| return s.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); | ||
| } | ||
| /** | ||
| * Rewrite every `</untrusted-content>` variant (case-insensitive, optional | ||
| * whitespace before `>`) inside `s` to a benign textual form, so an attacker | ||
| * who controls the wrapped content cannot terminate the envelope early. | ||
| */ | ||
| function escapeCloseTagSentinels(s) { | ||
| return s.replace(UNTRUSTED_CLOSE_TAG_VARIANT, ESCAPED_UNTRUSTED_CLOSE_TAG); | ||
| } | ||
| function unescapeCloseTagSentinels(s) { | ||
| return s.replaceAll(ESCAPED_UNTRUSTED_CLOSE_TAG, '</untrusted-content>'); | ||
| } | ||
| /** | ||
| * Wrap a single untrusted string in an `<untrusted-content source="…">` | ||
| * envelope, escaping any embedded close-tag variant so the envelope cannot be | ||
| * broken out of. | ||
| * | ||
| * `undefined` and `null` are passed through untouched so callers can apply the wrapper | ||
| * uniformly to optional fields without branching. | ||
| * | ||
| * Idempotent: when `text` is already a properly-shaped envelope for the SAME | ||
| * `source` (starts with the matching OPEN tag, ends with CLOSE, and contains no | ||
| * internal close-tag variants), the original string is returned unchanged so | ||
| * `wrapUntrusted(wrapUntrusted(s, src), src) === wrapUntrusted(s, src)`. | ||
| */ | ||
| export function wrapUntrusted(text, source) { | ||
| if (text === undefined || text === null) | ||
| return undefined; | ||
| const open = `<untrusted-content source="${escapeAttr(source)}">`; | ||
| const close = '</untrusted-content>'; | ||
| if (text.startsWith(open) && text.endsWith(close) && text.length >= open.length + close.length) { | ||
| const inner = text.slice(open.length, text.length - close.length); | ||
| if (!UNTRUSTED_CLOSE_TAG_VARIANT.test(inner)) { | ||
| UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0; // reset stateful /g regex | ||
| return text; | ||
| } | ||
| UNTRUSTED_CLOSE_TAG_VARIANT.lastIndex = 0; | ||
| } | ||
| return `${open}${escapeCloseTagSentinels(text)}${close}`; | ||
| } | ||
| /** | ||
| * Strip one `<untrusted-content>` envelope from `text` if present, returning raw | ||
| * strings unchanged. This is intentionally one-layer and idempotent for already | ||
| * raw input so callers can accept either displayed wrapped content or manually | ||
| * authored content. | ||
| */ | ||
| export function unwrapUntrusted(text) { | ||
| const match = UNTRUSTED_ENVELOPE.exec(text); | ||
| if (!match) | ||
| return text; | ||
| return unescapeCloseTagSentinels(match[1]); | ||
| } | ||
| /** | ||
| * Recursively wrap every string key and value reachable inside `value` (strings, | ||
| * arrays, plain-object property keys and values) in an `<untrusted-content>` envelope. | ||
| * | ||
| * Use this when a whole response blob is third-party data and you want to | ||
| * envelope it wholesale rather than enumerate fields. Non-string leaves (numbers, | ||
| * booleans, null) pass through unchanged. | ||
| */ | ||
| export function wrapUntrustedJsonStrings(value, source) { | ||
| if (typeof value === 'string') { | ||
| return wrapUntrusted(value, source); | ||
| } | ||
| if (Array.isArray(value)) { | ||
| return value.map((item) => wrapUntrustedJsonStrings(item, source)); | ||
| } | ||
| if (value && typeof value === 'object') { | ||
| return Object.fromEntries(Object.entries(value).map(([key, item]) => [ | ||
| wrapUntrusted(key, source) ?? key, | ||
| wrapUntrustedJsonStrings(item, source), | ||
| ])); | ||
| } | ||
| return value; | ||
| } | ||
| /** | ||
| * Recursively unwrap every string key and value reachable inside `value`. | ||
| * Non-string leaves pass through unchanged. | ||
| */ | ||
| export function unwrapUntrustedJsonStrings(value) { | ||
| if (typeof value === 'string') { | ||
| return unwrapUntrusted(value); | ||
| } | ||
| if (Array.isArray(value)) { | ||
| return value.map((item) => unwrapUntrustedJsonStrings(item)); | ||
| } | ||
| if (value && typeof value === 'object') { | ||
| return Object.fromEntries(Object.entries(value).map(([key, item]) => [ | ||
| unwrapUntrusted(key), | ||
| unwrapUntrustedJsonStrings(item), | ||
| ])); | ||
| } | ||
| return value; | ||
| } | ||
| //# sourceMappingURL=untrusted-content.js.map |
+24
-0
@@ -7,2 +7,3 @@ /** | ||
| */ | ||
| import { z } from 'zod'; | ||
| interface GoogleApiOptions { | ||
@@ -14,2 +15,4 @@ method?: 'GET' | 'POST'; | ||
| signal?: AbortSignal; | ||
| /** Internal/test override for the request timeout; defaults to 30s. */ | ||
| timeoutMs?: number; | ||
| } | ||
@@ -21,4 +24,17 @@ /** Bases the client may target. Re-exported for tests and tools. */ | ||
| readonly data: "https://analyticsdata.googleapis.com/v1beta"; | ||
| readonly dataAlpha: "https://analyticsdata.googleapis.com/v1alpha"; | ||
| }; | ||
| /** | ||
| * Hard cap on followed list pages. A misbehaving or compromised upstream | ||
| * that returns a perpetual nextPageToken must fail observably instead of | ||
| * looping forever and growing memory without bound. 250 pages at the | ||
| * connector's page sizes (100-200 items) is far beyond any legitimate GA4 | ||
| * collection. | ||
| */ | ||
| export declare const MAX_LIST_PAGES = 250; | ||
| /** | ||
| * Throw the shared observable failure for pagination that does not terminate. | ||
| */ | ||
| export declare function paginationLimitExceeded(context: string): never; | ||
| /** | ||
| * Call a Google API endpoint with the configured ADC bearer token. | ||
@@ -32,8 +48,16 @@ * Surfaces the API's `error.message` field on non-2xx responses. | ||
| * full enumeration is the expected mode. | ||
| * | ||
| * Every page's items are fail-closed validated against `itemSchema` at the | ||
| * boundary (INVALID_API_RESPONSE on shape mismatch) instead of being | ||
| * TypeScript-cast only. A non-string nextPageToken ends pagination rather | ||
| * than being coerced into a query parameter. | ||
| */ | ||
| export declare function paginate<T>(apiPath: string, options: { | ||
| itemKey: string; | ||
| itemSchema: z.ZodType<T>; | ||
| query?: GoogleApiOptions['query']; | ||
| baseUrl?: string; | ||
| }): Promise<T[]>; | ||
| /** Assert that a stripped resource ID is a single safe path segment. */ | ||
| export declare function assertResourceIdSegment(id: string, label: string): string; | ||
| /** Resolve `properties/<id>`, accepting either bare IDs or prefixed forms. */ | ||
@@ -40,0 +64,0 @@ export declare function propertyPath(propertyId?: string): string; |
+112
-17
@@ -7,4 +7,19 @@ /** | ||
| */ | ||
| import { z } from 'zod'; | ||
| import { getAccessToken } from './auth.js'; | ||
| import { ADMIN_BASE_URL, ADMIN_ALPHA_BASE_URL, DATA_BASE_URL, GoogleAnalyticsError, USER_AGENT, DEFAULT_REQUEST_TIMEOUT_MS, } from './types.js'; | ||
| import { ADMIN_BASE_URL, ADMIN_ALPHA_BASE_URL, DATA_BASE_URL, DATA_ALPHA_BASE_URL, GoogleAnalyticsError, USER_AGENT, DEFAULT_REQUEST_TIMEOUT_MS, } from './types.js'; | ||
| import { wrapUntrusted } from './untrusted-content.js'; | ||
| import { parseApiResponse, UNTRUSTED_SOURCES } from './utils.js'; | ||
| /** Shape of the standard Google API error payload, validated at the boundary. */ | ||
| const apiErrorPayloadSchema = z | ||
| .object({ | ||
| error: z | ||
| .object({ | ||
| message: z.string().optional(), | ||
| status: z.string().optional(), | ||
| }) | ||
| .passthrough() | ||
| .optional(), | ||
| }) | ||
| .passthrough(); | ||
| /** Bases the client may target. Re-exported for tests and tools. */ | ||
@@ -15,4 +30,19 @@ export const Bases = { | ||
| data: DATA_BASE_URL, | ||
| dataAlpha: DATA_ALPHA_BASE_URL, | ||
| }; | ||
| /** | ||
| * Hard cap on followed list pages. A misbehaving or compromised upstream | ||
| * that returns a perpetual nextPageToken must fail observably instead of | ||
| * looping forever and growing memory without bound. 250 pages at the | ||
| * connector's page sizes (100-200 items) is far beyond any legitimate GA4 | ||
| * collection. | ||
| */ | ||
| export const MAX_LIST_PAGES = 250; | ||
| /** | ||
| * Throw the shared observable failure for pagination that does not terminate. | ||
| */ | ||
| export function paginationLimitExceeded(context) { | ||
| throw new GoogleAnalyticsError(`Google API pagination for ${context} did not terminate after ${MAX_LIST_PAGES} pages.`, 'PAGINATION_LIMIT_EXCEEDED', 'The API kept returning a nextPageToken beyond the safety cap. Try again; if the problem persists, narrow the query or check for a connector update.'); | ||
| } | ||
| /** | ||
| * Call a Google API endpoint with the configured ADC bearer token. | ||
@@ -22,3 +52,3 @@ * Surfaces the API's `error.message` field on non-2xx responses. | ||
| export async function googleApi(apiPath, options = {}) { | ||
| const { method = 'GET', query, body, baseUrl = ADMIN_BASE_URL, signal } = options; | ||
| const { method = 'GET', query, body, baseUrl = ADMIN_BASE_URL, signal, timeoutMs } = options; | ||
| const token = await getAccessToken(); | ||
@@ -33,6 +63,14 @@ const url = new URL(`${baseUrl}${apiPath}`); | ||
| } | ||
| const controller = signal ? undefined : new AbortController(); | ||
| const timer = controller | ||
| ? setTimeout(() => controller.abort(), DEFAULT_REQUEST_TIMEOUT_MS) | ||
| : undefined; | ||
| // The request timeout is unconditional: an externally supplied signal must | ||
| // not disable it, so the external signal is forwarded into our own | ||
| // controller rather than replacing it. | ||
| const controller = new AbortController(); | ||
| const forwardAbort = () => controller.abort(); | ||
| if (signal) { | ||
| if (signal.aborted) | ||
| controller.abort(); | ||
| else | ||
| signal.addEventListener('abort', forwardAbort, { once: true }); | ||
| } | ||
| const timer = setTimeout(() => controller.abort(), timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS); | ||
| try { | ||
@@ -48,10 +86,40 @@ const response = await fetch(url, { | ||
| body: body ? JSON.stringify(body) : undefined, | ||
| signal: signal ?? controller?.signal, | ||
| signal: controller.signal, | ||
| }); | ||
| const text = await response.text(); | ||
| const data = text ? JSON.parse(text) : null; | ||
| // The runtime's JSON parse error can embed a fragment of the | ||
| // (vendor-controlled, potentially attacker-influenced) body; never let it | ||
| // propagate into model-visible output. Fail closed with a sanitised | ||
| // error instead. | ||
| let data = null; | ||
| if (text) { | ||
| try { | ||
| const parsed = JSON.parse(text); | ||
| if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { | ||
| throw new Error('non-object JSON body'); | ||
| } | ||
| data = parsed; | ||
| } | ||
| catch { | ||
| throw new GoogleAnalyticsError(`Google API returned a response that could not be parsed (HTTP ${response.status}).`, 'INVALID_API_RESPONSE', 'Try again. If the problem persists, the API response format may have changed — check for a connector update.'); | ||
| } | ||
| } | ||
| if (!response.ok) { | ||
| const apiError = data?.error; | ||
| const message = apiError?.message || `${response.status} ${response.statusText}`; | ||
| throw new GoogleAnalyticsError(message, apiError?.status || `HTTP_${response.status}`, 'Check that the credential has access to this resource and that the relevant Google APIs are enabled in the Cloud project attached to the credential.'); | ||
| const parsedError = apiErrorPayloadSchema.safeParse(data ?? {}); | ||
| const apiError = parsedError.success ? parsedError.data.error : undefined; | ||
| // Vendor error text is untrusted external content — envelope it before | ||
| // it reaches model-visible output (invariant #6). Never fall back to | ||
| // raw response.statusText, which is also vendor-controlled. | ||
| const message = apiError?.message | ||
| ? (wrapUntrusted(apiError.message, UNTRUSTED_SOURCES.apiError) ?? | ||
| `Google API request failed (HTTP ${response.status}).`) | ||
| : `Google API request failed (HTTP ${response.status}).`; | ||
| // The vendor-supplied `error.status` is untrusted text too: it only | ||
| // becomes the structured error code when it matches Google's enum shape | ||
| // (e.g. PERMISSION_DENIED). Anything else falls back to the numeric | ||
| // HTTP status, so arbitrary vendor text never reaches `code` raw. | ||
| const code = apiError?.status && /^[A-Z][A-Z0-9_]*$/.test(apiError.status) | ||
| ? apiError.status | ||
| : `HTTP_${response.status}`; | ||
| throw new GoogleAnalyticsError(message, code, 'Check that the credential has access to this resource and that the relevant Google APIs are enabled in the Cloud project attached to the credential.'); | ||
| } | ||
@@ -61,4 +129,5 @@ return data; | ||
| finally { | ||
| if (timer) | ||
| clearTimeout(timer); | ||
| clearTimeout(timer); | ||
| if (signal) | ||
| signal.removeEventListener('abort', forwardAbort); | ||
| } | ||
@@ -70,2 +139,7 @@ } | ||
| * full enumeration is the expected mode. | ||
| * | ||
| * Every page's items are fail-closed validated against `itemSchema` at the | ||
| * boundary (INVALID_API_RESPONSE on shape mismatch) instead of being | ||
| * TypeScript-cast only. A non-string nextPageToken ends pagination rather | ||
| * than being coerced into a query parameter. | ||
| */ | ||
@@ -75,3 +149,8 @@ export async function paginate(apiPath, options) { | ||
| let pageToken; | ||
| let pages = 0; | ||
| do { | ||
| pages += 1; | ||
| if (pages > MAX_LIST_PAGES) { | ||
| paginationLimitExceeded(options.itemKey); | ||
| } | ||
| const response = await googleApi(apiPath, { | ||
@@ -82,8 +161,24 @@ method: 'GET', | ||
| }); | ||
| const page = response?.[options.itemKey] || []; | ||
| const page = parseApiResponse(z.array(options.itemSchema), response?.[options.itemKey] ?? [], `${options.itemKey}.list`); | ||
| items.push(...page); | ||
| pageToken = response?.nextPageToken; | ||
| const rawToken = response?.nextPageToken; | ||
| pageToken = typeof rawToken === 'string' && rawToken !== '' ? rawToken : undefined; | ||
| } while (pageToken); | ||
| return items; | ||
| } | ||
| /** | ||
| * Resource IDs are interpolated into URL paths. Constrain them to the | ||
| * character set Google actually uses so traversal or query/fragment | ||
| * characters (`.`, `/`, `?`, `#`) can never reshape the request path — | ||
| * inputs arrive from tool arguments (model-influenced) or from vendor | ||
| * responses (e.g. a property's parent account). | ||
| */ | ||
| const RESOURCE_ID_PATTERN = /^[A-Za-z0-9_-]+$/; | ||
| /** Assert that a stripped resource ID is a single safe path segment. */ | ||
| export function assertResourceIdSegment(id, label) { | ||
| if (!RESOURCE_ID_PATTERN.test(id)) { | ||
| throw new GoogleAnalyticsError(`The ${label} contains characters that are not valid in a GA4 resource ID.`, 'INVALID_RESOURCE_ID', `Pass a bare ${label} (letters, digits, "_" or "-") or the full resource name exactly as returned by a list/get tool.`); | ||
| } | ||
| return id; | ||
| } | ||
| /** Resolve `properties/<id>`, accepting either bare IDs or prefixed forms. */ | ||
@@ -96,3 +191,3 @@ export function propertyPath(propertyId) { | ||
| const clean = String(resolved).replace(/^properties\//, ''); | ||
| return `properties/${clean}`; | ||
| return `properties/${assertResourceIdSegment(clean, 'property ID')}`; | ||
| } | ||
@@ -102,4 +197,4 @@ /** Resolve `accounts/<id>`, accepting either bare IDs or prefixed forms. */ | ||
| const clean = String(accountId).replace(/^accounts\//, ''); | ||
| return `accounts/${clean}`; | ||
| return `accounts/${assertResourceIdSegment(clean, 'account ID')}`; | ||
| } | ||
| //# sourceMappingURL=client.js.map |
+3
-1
| import { createRequire } from 'node:module'; | ||
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { registerAccountTools, registerAdminTools, registerReportTools, registerSchemaTools, } from './tools/index.js'; | ||
| import { registerAccountTools, registerAdminTools, registerAudienceExportTools, registerReportTaskTools, registerReportTools, registerSchemaTools, } from './tools/index.js'; | ||
| const require = createRequire(import.meta.url); | ||
@@ -15,4 +15,6 @@ const pkg = require('../package.json'); | ||
| registerAdminTools(server); | ||
| registerAudienceExportTools(server); | ||
| registerReportTaskTools(server); | ||
| return server; | ||
| } | ||
| //# sourceMappingURL=server.js.map |
@@ -7,3 +7,4 @@ /** | ||
| import { googleApi, paginate, propertyPath } from '../client.js'; | ||
| import { withErrorHandling } from '../utils.js'; | ||
| import { wrapUntrusted } from '../untrusted-content.js'; | ||
| import { parseApiResponse, UNTRUSTED_SOURCES, withErrorHandling } from '../utils.js'; | ||
| const READ_ONLY = { | ||
@@ -15,5 +16,39 @@ readOnlyHint: true, | ||
| }; | ||
| /** Runtime shapes validated at the boundary (fail-closed); .passthrough() | ||
| * keeps the surfaces forward-compatible with new vendor fields. */ | ||
| const accountSummarySchema = z | ||
| .object({ | ||
| account: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| propertySummaries: z | ||
| .array(z | ||
| .object({ | ||
| property: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| propertyType: z.string().optional(), | ||
| parent: z.string().optional(), | ||
| }) | ||
| .passthrough()) | ||
| .optional(), | ||
| }) | ||
| .passthrough(); | ||
| const propertyDetailsSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| propertyType: z.string().optional(), | ||
| parent: z.string().optional(), | ||
| currencyCode: z.string().optional(), | ||
| timeZone: z.string().optional(), | ||
| industryCategory: z.string().optional(), | ||
| serviceLevel: z.string().optional(), | ||
| createTime: z.string().optional(), | ||
| updateTime: z.string().optional(), | ||
| deleted: z.boolean().optional(), | ||
| }) | ||
| .passthrough(); | ||
| async function listAccountSummariesRaw() { | ||
| return paginate('/accountSummaries', { | ||
| itemKey: 'accountSummaries', | ||
| itemSchema: accountSummarySchema, | ||
| query: { pageSize: 200 }, | ||
@@ -33,6 +68,6 @@ }); | ||
| account: summary.account, | ||
| displayName: summary.displayName, | ||
| displayName: wrapUntrusted(summary.displayName, UNTRUSTED_SOURCES.admin), | ||
| propertySummaries: (summary.propertySummaries || []).map((property) => ({ | ||
| property: property.property, | ||
| displayName: property.displayName, | ||
| displayName: wrapUntrusted(property.displayName, UNTRUSTED_SOURCES.admin), | ||
| propertyType: property.propertyType, | ||
@@ -72,5 +107,5 @@ parent: property.parent, | ||
| account_id: accountId, | ||
| account_name: accountSummary.displayName, | ||
| account_name: wrapUntrusted(accountSummary.displayName, UNTRUSTED_SOURCES.admin), | ||
| property_id: property.property?.replace(/^properties\//, '') || null, | ||
| property_name: property.displayName, | ||
| property_name: wrapUntrusted(property.displayName, UNTRUSTED_SOURCES.admin), | ||
| property_type: property.propertyType || null, | ||
@@ -92,7 +127,7 @@ parent: property.parent || null, | ||
| }, withErrorHandling(async (args) => { | ||
| const property = await googleApi(`/${propertyPath(args.property_id)}`); | ||
| const property = parseApiResponse(propertyDetailsSchema, await googleApi(`/${propertyPath(args.property_id)}`), 'properties.get'); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| property_id: property.name?.replace(/^properties\//, '') || null, | ||
| displayName: property.displayName || null, | ||
| displayName: wrapUntrusted(property.displayName, UNTRUSTED_SOURCES.admin) || null, | ||
| propertyType: property.propertyType || null, | ||
@@ -99,0 +134,0 @@ parent: property.parent || null, |
+289
-33
@@ -7,5 +7,6 @@ /** | ||
| import { z } from 'zod'; | ||
| import { googleApi, paginate, propertyPath, accountPath, Bases } from '../client.js'; | ||
| import { googleApi, paginate, propertyPath, accountPath, assertResourceIdSegment, Bases, MAX_LIST_PAGES, paginationLimitExceeded } from '../client.js'; | ||
| import { GoogleAnalyticsError } from '../types.js'; | ||
| import { withErrorHandling } from '../utils.js'; | ||
| import { wrapUntrusted, wrapUntrustedJsonStrings } from '../untrusted-content.js'; | ||
| import { parseApiResponse, UNTRUSTED_SOURCES, withErrorHandling } from '../utils.js'; | ||
| const READ_ONLY = { | ||
@@ -20,2 +21,156 @@ readOnlyHint: true, | ||
| }); | ||
| /** | ||
| * Runtime shapes of Admin API resources, validated at the boundary | ||
| * (fail-closed) instead of only TypeScript-cast. .passthrough() keeps the | ||
| * surfaces forward-compatible with new vendor fields. | ||
| */ | ||
| const customDimensionSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| parameterName: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| description: z.string().optional(), | ||
| scope: z.string().optional(), | ||
| disallowAdsPersonalization: z.boolean().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const customMetricSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| parameterName: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| description: z.string().optional(), | ||
| measurementUnit: z.string().optional(), | ||
| restrictedMetricType: z.array(z.string()).optional(), | ||
| }) | ||
| .passthrough(); | ||
| const dataStreamSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| type: z.string().optional(), | ||
| createTime: z.string().optional(), | ||
| updateTime: z.string().optional(), | ||
| webStreamData: z | ||
| .object({ | ||
| defaultUri: z.string().optional(), | ||
| measurementId: z.string().optional(), | ||
| }) | ||
| .passthrough() | ||
| .optional(), | ||
| androidAppStreamData: z | ||
| .object({ | ||
| packageName: z.string().optional(), | ||
| firebaseAppId: z.string().optional(), | ||
| }) | ||
| .passthrough() | ||
| .optional(), | ||
| iosAppStreamData: z | ||
| .object({ | ||
| bundleId: z.string().optional(), | ||
| firebaseAppId: z.string().optional(), | ||
| }) | ||
| .passthrough() | ||
| .optional(), | ||
| }) | ||
| .passthrough(); | ||
| const googleAdsLinkSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| customerId: z.string().optional(), | ||
| canManageClients: z.boolean().optional(), | ||
| adsPersonalizationEnabled: z.boolean().optional(), | ||
| creatorEmailAddress: z.string().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const keyEventSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| eventName: z.string().optional(), | ||
| createTime: z.string().optional(), | ||
| countingMethod: z.string().optional(), | ||
| defaultValue: z.union([z.number(), z.string()]).optional(), | ||
| deletable: z.boolean().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const audienceSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| description: z.string().optional(), | ||
| membershipDurationDays: z.number().optional(), | ||
| adsPersonalizationEnabled: z.boolean().optional(), | ||
| exclusionDurationMode: z.string().optional(), | ||
| filterClauses: z.array(z.unknown()).optional(), | ||
| createTime: z.string().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const channelGroupSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| displayName: z.string().optional(), | ||
| description: z.string().optional(), | ||
| systemDefined: z.boolean().optional(), | ||
| groupingRule: z.array(z.unknown()).optional(), | ||
| }) | ||
| .passthrough(); | ||
| const bigQueryLinkSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| project: z.string().optional(), | ||
| exportStreams: z.array(z.string()).optional(), | ||
| dailyExportEnabled: z.boolean().optional(), | ||
| streamingExportEnabled: z.boolean().optional(), | ||
| freshDailyExportEnabled: z.boolean().optional(), | ||
| includeAdvertisingId: z.boolean().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const firebaseLinkSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| project: z.string().optional(), | ||
| createTime: z.string().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const dataRetentionSettingsSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| eventDataRetention: z.string().optional(), | ||
| resetUserDataOnNewActivity: z.boolean().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const globalSiteTagSchema = z | ||
| .object({ | ||
| name: z.string().optional(), | ||
| snippet: z.string().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const propertyParentSchema = z | ||
| .object({ | ||
| parent: z.string().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const changeHistoryEventSchema = z | ||
| .object({ | ||
| id: z.string().optional(), | ||
| changeTime: z.string().optional(), | ||
| actorType: z.string().optional(), | ||
| userActorEmail: z.string().optional(), | ||
| changes: z | ||
| .array(z | ||
| .object({ | ||
| action: z.string().optional(), | ||
| resource: z.string().optional(), | ||
| resourceAfterChange: z.unknown().optional(), | ||
| }) | ||
| .passthrough()) | ||
| .optional(), | ||
| }) | ||
| .passthrough(); | ||
| const changeHistoryResponseSchema = z | ||
| .object({ | ||
| changeHistoryEvents: z.array(changeHistoryEventSchema).optional(), | ||
| nextPageToken: z.string().optional(), | ||
| }) | ||
| .passthrough(); | ||
| export function registerAdminTools(server) { | ||
@@ -31,2 +186,3 @@ server.registerTool('ga_get_custom_dimensions_and_metrics', { | ||
| itemKey: 'customDimensions', | ||
| itemSchema: customDimensionSchema, | ||
| query: { pageSize: 200 }, | ||
@@ -36,2 +192,3 @@ }), | ||
| itemKey: 'customMetrics', | ||
| itemSchema: customMetricSchema, | ||
| query: { pageSize: 200 }, | ||
@@ -46,4 +203,4 @@ }), | ||
| parameterName: item.parameterName || null, | ||
| displayName: item.displayName || null, | ||
| description: item.description || null, | ||
| displayName: wrapUntrusted(item.displayName, UNTRUSTED_SOURCES.admin) || null, | ||
| description: wrapUntrusted(item.description, UNTRUSTED_SOURCES.admin) || null, | ||
| scope: item.scope || null, | ||
@@ -55,4 +212,4 @@ disallowAdsPersonalization: item.disallowAdsPersonalization || false, | ||
| parameterName: item.parameterName || null, | ||
| displayName: item.displayName || null, | ||
| description: item.description || null, | ||
| displayName: wrapUntrusted(item.displayName, UNTRUSTED_SOURCES.admin) || null, | ||
| description: wrapUntrusted(item.description, UNTRUSTED_SOURCES.admin) || null, | ||
| measurementUnit: item.measurementUnit || null, | ||
@@ -71,2 +228,3 @@ restrictedMetricType: item.restrictedMetricType || [], | ||
| itemKey: 'googleAdsLinks', | ||
| itemSchema: googleAdsLinkSchema, | ||
| query: { pageSize: 200 }, | ||
@@ -82,3 +240,3 @@ }); | ||
| adsPersonalizationEnabled: link.adsPersonalizationEnabled || false, | ||
| creatorEmailAddress: link.creatorEmailAddress || null, | ||
| creatorEmailAddress: wrapUntrusted(link.creatorEmailAddress, UNTRUSTED_SOURCES.admin) || null, | ||
| })), | ||
@@ -95,2 +253,3 @@ }); | ||
| itemKey: 'keyEvents', | ||
| itemSchema: keyEventSchema, | ||
| query: { pageSize: 200 }, | ||
@@ -103,6 +262,10 @@ }); | ||
| name: item.name || null, | ||
| eventName: item.eventName || null, | ||
| eventName: wrapUntrusted(item.eventName, UNTRUSTED_SOURCES.admin) || null, | ||
| createTime: item.createTime || null, | ||
| countingMethod: item.countingMethod || null, | ||
| defaultValue: item.defaultValue || null, | ||
| // Property-editor-authored value — envelope string content | ||
| // (invariant #6); numbers pass through unchanged. | ||
| defaultValue: item.defaultValue | ||
| ? wrapUntrustedJsonStrings(item.defaultValue, UNTRUSTED_SOURCES.admin) | ||
| : null, | ||
| deletable: item.deletable || false, | ||
@@ -120,2 +283,3 @@ })), | ||
| itemKey: 'dataStreams', | ||
| itemSchema: dataStreamSchema, | ||
| query: { pageSize: 200 }, | ||
@@ -128,12 +292,74 @@ }); | ||
| name: stream.name || null, | ||
| displayName: stream.displayName || null, | ||
| displayName: wrapUntrusted(stream.displayName, UNTRUSTED_SOURCES.admin) || null, | ||
| type: stream.type || null, | ||
| createTime: stream.createTime || null, | ||
| updateTime: stream.updateTime || null, | ||
| webStreamData: stream.webStreamData || null, | ||
| androidAppStreamData: stream.androidAppStreamData || null, | ||
| iosAppStreamData: stream.iosAppStreamData || null, | ||
| // Stream-data blobs are property-editor-authored (the defaultUri is | ||
| // the editor's typed website URL) — enveloped wholesale, the same | ||
| // treatment as audience filter clauses (invariant #6). | ||
| webStreamData: stream.webStreamData | ||
| ? wrapUntrustedJsonStrings(stream.webStreamData, UNTRUSTED_SOURCES.admin) | ||
| : null, | ||
| androidAppStreamData: stream.androidAppStreamData | ||
| ? wrapUntrustedJsonStrings(stream.androidAppStreamData, UNTRUSTED_SOURCES.admin) | ||
| : null, | ||
| iosAppStreamData: stream.iosAppStreamData | ||
| ? wrapUntrustedJsonStrings(stream.iosAppStreamData, UNTRUSTED_SOURCES.admin) | ||
| : null, | ||
| })), | ||
| }); | ||
| })); | ||
| server.registerTool('ga_list_audiences', { | ||
| description: 'List all audiences configured on a GA4 property, including membership duration, ads-personalization flag, and filter clauses. Uses the v1alpha Admin API (audiences are not yet promoted to v1beta); structure may evolve over time.', | ||
| inputSchema: requiredPropertyId.shape, | ||
| annotations: READ_ONLY, | ||
| }, withErrorHandling(async (args) => { | ||
| const property = propertyPath(args.property_id); | ||
| const audiences = await paginate(`/${property}/audiences`, { | ||
| itemKey: 'audiences', | ||
| itemSchema: audienceSchema, | ||
| query: { pageSize: 200 }, | ||
| baseUrl: Bases.adminAlpha, | ||
| }); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| property, | ||
| audiences: audiences.map((audience) => ({ | ||
| name: audience.name || null, | ||
| displayName: wrapUntrusted(audience.displayName, UNTRUSTED_SOURCES.admin) || null, | ||
| description: wrapUntrusted(audience.description, UNTRUSTED_SOURCES.admin) || null, | ||
| membershipDurationDays: audience.membershipDurationDays ?? null, | ||
| adsPersonalizationEnabled: audience.adsPersonalizationEnabled || false, | ||
| exclusionDurationMode: audience.exclusionDurationMode || null, | ||
| // User-authored definition blob — enveloped wholesale. | ||
| filterClauses: wrapUntrustedJsonStrings(audience.filterClauses || [], UNTRUSTED_SOURCES.admin), | ||
| createTime: audience.createTime || null, | ||
| })), | ||
| }); | ||
| })); | ||
| server.registerTool('ga_list_channel_groups', { | ||
| description: 'List all channel groups configured on a GA4 property, including the grouping rules that define channels such as "Organic Social". Uses the v1alpha Admin API (channel groups are not yet promoted to v1beta); structure may evolve over time.', | ||
| inputSchema: requiredPropertyId.shape, | ||
| annotations: READ_ONLY, | ||
| }, withErrorHandling(async (args) => { | ||
| const property = propertyPath(args.property_id); | ||
| const channelGroups = await paginate(`/${property}/channelGroups`, { | ||
| itemKey: 'channelGroups', | ||
| itemSchema: channelGroupSchema, | ||
| query: { pageSize: 200 }, | ||
| baseUrl: Bases.adminAlpha, | ||
| }); | ||
| return JSON.stringify({ | ||
| ok: true, | ||
| property, | ||
| channelGroups: channelGroups.map((group) => ({ | ||
| name: group.name || null, | ||
| displayName: wrapUntrusted(group.displayName, UNTRUSTED_SOURCES.admin) || null, | ||
| description: wrapUntrusted(group.description, UNTRUSTED_SOURCES.admin) || null, | ||
| systemDefined: group.systemDefined || false, | ||
| // User-authored definition blob — enveloped wholesale. | ||
| groupingRule: wrapUntrustedJsonStrings(group.groupingRule || [], UNTRUSTED_SOURCES.admin), | ||
| })), | ||
| }); | ||
| })); | ||
| server.registerTool('ga_get_global_site_tag', { | ||
@@ -147,2 +373,3 @@ description: 'Get the gtag.js / global site tag snippet for the first web data stream on a GA4 property.', | ||
| itemKey: 'dataStreams', | ||
| itemSchema: dataStreamSchema, | ||
| query: { pageSize: 200 }, | ||
@@ -154,4 +381,8 @@ }); | ||
| } | ||
| const streamId = webStream.name.split('/').pop(); | ||
| const response = await googleApi(`/${property}/dataStreams/${streamId}/globalSiteTag`); | ||
| // The stream ID comes from a vendor-supplied resource name; confine it | ||
| // to a single safe path segment before URL interpolation. | ||
| const streamId = assertResourceIdSegment(webStream.name.split('/').pop() ?? '', 'data stream ID'); | ||
| const response = parseApiResponse(globalSiteTagSchema, await googleApi(`/${property}/dataStreams/${streamId}/globalSiteTag`, { | ||
| baseUrl: Bases.adminAlpha, | ||
| }), 'globalSiteTag.get'); | ||
| return JSON.stringify({ | ||
@@ -161,4 +392,6 @@ ok: true, | ||
| dataStream: webStream.name, | ||
| displayName: webStream.displayName || null, | ||
| globalSiteTag: response?.snippet || null, | ||
| displayName: wrapUntrusted(webStream.displayName, UNTRUSTED_SOURCES.admin) || null, | ||
| // The gtag.js snippet is external-system text rendered into model | ||
| // context — envelope it like every other vendor string (invariant #6). | ||
| globalSiteTag: wrapUntrusted(response?.snippet, UNTRUSTED_SOURCES.admin) || null, | ||
| globalSiteTagName: response?.name || null, | ||
@@ -175,3 +408,5 @@ }); | ||
| itemKey: 'bigQueryLinks', | ||
| itemSchema: bigQueryLinkSchema, | ||
| query: { pageSize: 200 }, | ||
| baseUrl: Bases.adminAlpha, | ||
| }); | ||
@@ -198,3 +433,3 @@ return JSON.stringify({ | ||
| const property = propertyPath(args.property_id); | ||
| const response = await googleApi(`/${property}/dataRetentionSettings`); | ||
| const response = parseApiResponse(dataRetentionSettingsSchema, await googleApi(`/${property}/dataRetentionSettings`), 'dataRetentionSettings.get'); | ||
| return JSON.stringify({ | ||
@@ -216,2 +451,3 @@ ok: true, | ||
| itemKey: 'firebaseLinks', | ||
| itemSchema: firebaseLinkSchema, | ||
| query: { pageSize: 200 }, | ||
@@ -230,3 +466,3 @@ }); | ||
| server.registerTool('ga_search_change_history_events', { | ||
| description: 'Search the change history (created/updated/deleted) for a GA4 property. Uses the v1alpha admin API; structure may evolve over time.', | ||
| description: 'Search the change history (created/updated/deleted) for a GA4 property. Follows all result pages automatically, so large histories are returned in full. Uses the v1alpha admin API; structure may evolve over time.', | ||
| inputSchema: requiredPropertyId.shape, | ||
@@ -236,3 +472,3 @@ annotations: READ_ONLY, | ||
| const property = propertyPath(args.property_id); | ||
| const propertyDetails = await googleApi(`/${property}`); | ||
| const propertyDetails = parseApiResponse(propertyParentSchema, await googleApi(`/${property}`), 'properties.get'); | ||
| const parentAccount = propertyDetails.parent; | ||
@@ -242,12 +478,28 @@ if (!parentAccount) { | ||
| } | ||
| const response = await googleApi(`/${accountPath(parentAccount)}:searchChangeHistoryEvents`, { | ||
| method: 'POST', | ||
| body: { | ||
| resourceType: ['PROPERTY'], | ||
| action: ['CREATED', 'UPDATED', 'DELETED'], | ||
| property, | ||
| pageSize: 100, | ||
| }, | ||
| baseUrl: Bases.adminAlpha, | ||
| }); | ||
| // Follow every page — the previous single-shot request silently | ||
| // truncated the history at the first 100 events. The page cap keeps a | ||
| // misbehaving upstream from looping forever; hitting it is an | ||
| // observable error, never a silent truncation. | ||
| const events = []; | ||
| let pageToken; | ||
| let pages = 0; | ||
| do { | ||
| pages += 1; | ||
| if (pages > MAX_LIST_PAGES) { | ||
| paginationLimitExceeded('changeHistoryEvents'); | ||
| } | ||
| const response = parseApiResponse(changeHistoryResponseSchema, await googleApi(`/${accountPath(parentAccount)}:searchChangeHistoryEvents`, { | ||
| method: 'POST', | ||
| body: { | ||
| resourceType: ['PROPERTY'], | ||
| action: ['CREATED', 'UPDATED', 'DELETED'], | ||
| property, | ||
| pageSize: 100, | ||
| pageToken, | ||
| }, | ||
| baseUrl: Bases.adminAlpha, | ||
| }), 'searchChangeHistoryEvents'); | ||
| events.push(...(response.changeHistoryEvents || [])); | ||
| pageToken = response.nextPageToken || undefined; | ||
| } while (pageToken); | ||
| return JSON.stringify({ | ||
@@ -257,11 +509,15 @@ ok: true, | ||
| account: parentAccount, | ||
| changeHistoryEvents: (response.changeHistoryEvents || []).map((event) => ({ | ||
| changeHistoryEvents: events.map((event) => ({ | ||
| id: event.id || null, | ||
| changeTime: event.changeTime || null, | ||
| actorType: event.actorType || null, | ||
| userActorEmail: event.userActorEmail || null, | ||
| userActorEmail: wrapUntrusted(event.userActorEmail, UNTRUSTED_SOURCES.admin) || null, | ||
| changesFiltered: (event.changes || []).map((change) => ({ | ||
| action: change.action || null, | ||
| resource: change.resource || null, | ||
| resourceAfterChange: change.resourceAfterChange || null, | ||
| // Arbitrary resource snapshot authored in the external system — | ||
| // enveloped wholesale rather than field-enumerated. | ||
| resourceAfterChange: change.resourceAfterChange | ||
| ? wrapUntrustedJsonStrings(change.resourceAfterChange, UNTRUSTED_SOURCES.admin) | ||
| : null, | ||
| })), | ||
@@ -268,0 +524,0 @@ })), |
@@ -5,2 +5,4 @@ export { registerAccountTools } from './account.js'; | ||
| export { registerAdminTools } from './admin.js'; | ||
| export { registerAudienceExportTools } from './audience-exports.js'; | ||
| export { registerReportTaskTools } from './report-tasks.js'; | ||
| //# sourceMappingURL=index.d.ts.map |
@@ -5,2 +5,4 @@ export { registerAccountTools } from './account.js'; | ||
| export { registerAdminTools } from './admin.js'; | ||
| export { registerAudienceExportTools } from './audience-exports.js'; | ||
| export { registerReportTaskTools } from './report-tasks.js'; | ||
| //# sourceMappingURL=index.js.map |
+27
-19
@@ -11,4 +11,6 @@ /** | ||
| import { googleApi, propertyPath, Bases } from '../client.js'; | ||
| import { DEFAULT_ROW_WARNING_THRESHOLD } from '../types.js'; | ||
| import { compactObject, dimensionOrderBy, formatRows, parseOrderBy, toNameList, withErrorHandling, } from '../utils.js'; | ||
| import { filterExpressionSchema } from '../filters.js'; | ||
| import { dataApiResponseSchema, DEFAULT_ROW_WARNING_THRESHOLD, } from '../types.js'; | ||
| import { compactObject, dimensionOrderBy, formatRows, parseApiResponse, parseOrderBy, toNameList, UNTRUSTED_SOURCES, withErrorHandling, } from '../utils.js'; | ||
| import { wrapUntrustedJsonStrings } from '../untrusted-content.js'; | ||
| const READ_ONLY = { | ||
@@ -47,4 +49,8 @@ readOnlyHint: true, | ||
| .describe('Order field, prefix with - for descending.'), | ||
| dimension_filter: z.any().optional().describe('Raw GA4 dimensionFilter object.'), | ||
| metric_filter: z.any().optional().describe('Raw GA4 metricFilter object.'), | ||
| dimension_filter: filterExpressionSchema | ||
| .optional() | ||
| .describe('GA4 dimensionFilter (FilterExpression object).'), | ||
| metric_filter: filterExpressionSchema | ||
| .optional() | ||
| .describe('GA4 metricFilter (FilterExpression object).'), | ||
| keep_empty_rows: z.boolean().optional(), | ||
@@ -77,4 +83,4 @@ return_property_quota: z.boolean().optional(), | ||
| .optional(), | ||
| dimension_filter: z.any().optional(), | ||
| metric_filter: z.any().optional(), | ||
| dimension_filter: filterExpressionSchema.optional(), | ||
| metric_filter: filterExpressionSchema.optional(), | ||
| return_property_quota: z.boolean().optional(), | ||
@@ -96,4 +102,4 @@ }; | ||
| .min(1), | ||
| dimension_filter: z.any().optional(), | ||
| metric_filter: z.any().optional(), | ||
| dimension_filter: filterExpressionSchema.optional(), | ||
| metric_filter: filterExpressionSchema.optional(), | ||
| keep_empty_rows: z.boolean().optional(), | ||
@@ -112,7 +118,7 @@ return_property_quota: z.boolean().optional(), | ||
| }; | ||
| const response = await googleApi(`/${property}:runReport`, { | ||
| const response = parseApiResponse(dataApiResponseSchema, await googleApi(`/${property}:runReport`, { | ||
| method: 'POST', | ||
| body: sampleRequest, | ||
| baseUrl: Bases.data, | ||
| }); | ||
| }), 'runReport (row estimate)'); | ||
| return response.rowCount ?? 0; | ||
@@ -198,7 +204,7 @@ } | ||
| } | ||
| const response = await googleApi(`/${property}:runReport`, { | ||
| const response = parseApiResponse(dataApiResponseSchema, await googleApi(`/${property}:runReport`, { | ||
| method: 'POST', | ||
| body: finalRequest, | ||
| baseUrl: Bases.data, | ||
| }); | ||
| }), 'runReport'); | ||
| return { | ||
@@ -234,3 +240,3 @@ property, | ||
| const property = propertyPath(args.property_id); | ||
| const response = await googleApi(`/${property}:runPivotReport`, { | ||
| const response = parseApiResponse(dataApiResponseSchema, await googleApi(`/${property}:runPivotReport`, { | ||
| method: 'POST', | ||
@@ -253,3 +259,3 @@ body: compactObject({ | ||
| baseUrl: Bases.data, | ||
| }); | ||
| }), 'runPivotReport'); | ||
| return JSON.stringify({ | ||
@@ -260,3 +266,5 @@ ok: true, | ||
| endDate: args.end_date, | ||
| pivots: response.pivotHeaders || [], | ||
| // Vendor-echoed pivot header blob (dimension names/values) — enveloped | ||
| // wholesale rather than field-enumerated (invariant #6). | ||
| pivots: wrapUntrustedJsonStrings(response.pivotHeaders || [], UNTRUSTED_SOURCES.report), | ||
| ...formatRows(response), | ||
@@ -286,3 +294,3 @@ propertyQuota: response.propertyQuota || undefined, | ||
| const property = propertyPath(args.property_id); | ||
| const response = await googleApi(`/${property}:runRealtimeReport`, { | ||
| const response = parseApiResponse(dataApiResponseSchema, await googleApi(`/${property}:runRealtimeReport`, { | ||
| method: 'POST', | ||
@@ -299,3 +307,3 @@ body: compactObject({ | ||
| baseUrl: Bases.data, | ||
| }); | ||
| }), 'runRealtimeReport'); | ||
| return JSON.stringify({ | ||
@@ -314,3 +322,3 @@ ok: true, | ||
| const property = propertyPath(args.property_id); | ||
| const response = await googleApi(`/${property}:runReport`, { | ||
| const response = parseApiResponse(dataApiResponseSchema, await googleApi(`/${property}:runReport`, { | ||
| method: 'POST', | ||
@@ -324,3 +332,3 @@ body: { | ||
| baseUrl: Bases.data, | ||
| }); | ||
| }), 'runReport (quota snapshot)'); | ||
| return JSON.stringify({ | ||
@@ -327,0 +335,0 @@ ok: true, |
+31
-5
@@ -7,3 +7,3 @@ /** | ||
| import { googleApi, propertyPath, Bases } from '../client.js'; | ||
| import { mapMetadataField, toNameList, withErrorHandling } from '../utils.js'; | ||
| import { mapMetadataField, metadataFieldSchema, parseApiResponse, toNameList, withErrorHandling, } from '../utils.js'; | ||
| const READ_ONLY = { | ||
@@ -15,7 +15,33 @@ readOnlyHint: true, | ||
| }; | ||
| /** Runtime response shapes validated at the boundary (fail-closed). */ | ||
| const metadataResponseSchema = z | ||
| .object({ | ||
| dimensions: z.array(metadataFieldSchema).optional(), | ||
| metrics: z.array(metadataFieldSchema).optional(), | ||
| }) | ||
| .passthrough(); | ||
| const compatibilityEntrySchema = z | ||
| .object({ | ||
| dimensionMetadata: z | ||
| .object({ apiName: z.string().optional() }) | ||
| .passthrough() | ||
| .optional(), | ||
| metricMetadata: z | ||
| .object({ apiName: z.string().optional() }) | ||
| .passthrough() | ||
| .optional(), | ||
| compatibility: z.string().optional(), | ||
| }) | ||
| .passthrough(); | ||
| const compatibilityResponseSchema = z | ||
| .object({ | ||
| dimensionCompatibilities: z.array(compatibilityEntrySchema).optional(), | ||
| metricCompatibilities: z.array(compatibilityEntrySchema).optional(), | ||
| }) | ||
| .passthrough(); | ||
| async function fetchMetadata(propertyId) { | ||
| const property = propertyPath(propertyId); | ||
| const response = await googleApi(`/${property}/metadata`, { | ||
| const response = parseApiResponse(metadataResponseSchema, await googleApi(`/${property}/metadata`, { | ||
| baseUrl: Bases.data, | ||
| }); | ||
| }), 'metadata.get'); | ||
| return { | ||
@@ -188,3 +214,3 @@ property, | ||
| const metrics = toNameList(args.metrics); | ||
| const response = await googleApi(`/${property}:checkCompatibility`, { | ||
| const response = parseApiResponse(compatibilityResponseSchema, await googleApi(`/${property}:checkCompatibility`, { | ||
| method: 'POST', | ||
@@ -197,3 +223,3 @@ body: { | ||
| baseUrl: Bases.data, | ||
| }); | ||
| }), 'checkCompatibility'); | ||
| return JSON.stringify({ | ||
@@ -200,0 +226,0 @@ ok: true, |
+429
-37
| /** | ||
| * Shared types and constants for the Google Analytics MCP server. | ||
| */ | ||
| import { z } from 'zod'; | ||
| export declare const ANALYTICS_SCOPE = "https://www.googleapis.com/auth/analytics.readonly"; | ||
@@ -9,4 +10,5 @@ /** Default base URL for the GA Admin API. v1beta is generally available. */ | ||
| * v1alpha base URL — only used for endpoints that are not yet promoted to | ||
| * v1beta. Currently only `searchChangeHistoryEvents`. Keep this surface | ||
| * narrow; alpha endpoints can change without notice. | ||
| * v1beta. Currently `searchChangeHistoryEvents`, `bigQueryLinks`, | ||
| * `dataStreams.globalSiteTag`, `audiences`, and `channelGroups`. Keep this | ||
| * surface narrow; alpha endpoints can change without notice. | ||
| */ | ||
@@ -16,2 +18,8 @@ export declare const ADMIN_ALPHA_BASE_URL = "https://analyticsadmin.googleapis.com/v1alpha"; | ||
| export declare const DATA_BASE_URL = "https://analyticsdata.googleapis.com/v1beta"; | ||
| /** | ||
| * v1alpha base URL for the GA Data API — only used for endpoints that are | ||
| * not yet promoted to v1beta. Currently only `reportTasks`. Keep this | ||
| * surface narrow; alpha endpoints can change without notice. | ||
| */ | ||
| export declare const DATA_ALPHA_BASE_URL = "https://analyticsdata.googleapis.com/v1alpha"; | ||
| /** Threshold above which run_report warns and asks for explicit opt-in. */ | ||
@@ -28,37 +36,421 @@ export declare const DEFAULT_ROW_WARNING_THRESHOLD = 2500; | ||
| } | ||
| /** Response shape from the Data API runReport / runPivotReport / runRealtimeReport calls. */ | ||
| export interface DataApiResponse { | ||
| rowCount?: number; | ||
| dimensionHeaders?: Array<{ | ||
| name: string; | ||
| }>; | ||
| metricHeaders?: Array<{ | ||
| name: string; | ||
| }>; | ||
| rows?: Array<{ | ||
| dimensionValues?: Array<{ | ||
| value: string; | ||
| }>; | ||
| metricValues?: Array<{ | ||
| value: string; | ||
| }>; | ||
| }>; | ||
| totals?: Array<{ | ||
| metricValues?: Array<{ | ||
| value: string; | ||
| }>; | ||
| }>; | ||
| maximums?: Array<{ | ||
| metricValues?: Array<{ | ||
| value: string; | ||
| }>; | ||
| }>; | ||
| minimums?: Array<{ | ||
| metricValues?: Array<{ | ||
| value: string; | ||
| }>; | ||
| }>; | ||
| pivotHeaders?: unknown[]; | ||
| propertyQuota?: unknown; | ||
| } | ||
| export declare const dataApiResponseSchema: z.ZodObject<{ | ||
| rowCount: z.ZodOptional<z.ZodNumber>; | ||
| dimensionHeaders: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricHeaders: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| rows: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| totals: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| maximums: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| minimums: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| pivotHeaders: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>; | ||
| propertyQuota: z.ZodOptional<z.ZodUnknown>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| rowCount: z.ZodOptional<z.ZodNumber>; | ||
| dimensionHeaders: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricHeaders: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| rows: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| totals: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| maximums: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| minimums: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| pivotHeaders: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>; | ||
| propertyQuota: z.ZodOptional<z.ZodUnknown>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| rowCount: z.ZodOptional<z.ZodNumber>; | ||
| dimensionHeaders: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricHeaders: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| name: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| rows: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| dimensionValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| totals: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| maximums: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| minimums: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| metricValues: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| value: z.ZodOptional<z.ZodString>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>, "many">>; | ||
| pivotHeaders: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>; | ||
| propertyQuota: z.ZodOptional<z.ZodUnknown>; | ||
| }, z.ZodTypeAny, "passthrough">>; | ||
| export type DataApiResponse = z.infer<typeof dataApiResponseSchema>; | ||
| //# sourceMappingURL=types.d.ts.map |
+46
-2
| /** | ||
| * Shared types and constants for the Google Analytics MCP server. | ||
| */ | ||
| import { z } from 'zod'; | ||
| export const ANALYTICS_SCOPE = 'https://www.googleapis.com/auth/analytics.readonly'; | ||
@@ -9,4 +10,5 @@ /** Default base URL for the GA Admin API. v1beta is generally available. */ | ||
| * v1alpha base URL — only used for endpoints that are not yet promoted to | ||
| * v1beta. Currently only `searchChangeHistoryEvents`. Keep this surface | ||
| * narrow; alpha endpoints can change without notice. | ||
| * v1beta. Currently `searchChangeHistoryEvents`, `bigQueryLinks`, | ||
| * `dataStreams.globalSiteTag`, `audiences`, and `channelGroups`. Keep this | ||
| * surface narrow; alpha endpoints can change without notice. | ||
| */ | ||
@@ -16,2 +18,8 @@ export const ADMIN_ALPHA_BASE_URL = 'https://analyticsadmin.googleapis.com/v1alpha'; | ||
| export const DATA_BASE_URL = 'https://analyticsdata.googleapis.com/v1beta'; | ||
| /** | ||
| * v1alpha base URL for the GA Data API — only used for endpoints that are | ||
| * not yet promoted to v1beta. Currently only `reportTasks`. Keep this | ||
| * surface narrow; alpha endpoints can change without notice. | ||
| */ | ||
| export const DATA_ALPHA_BASE_URL = 'https://analyticsdata.googleapis.com/v1alpha'; | ||
| /** Threshold above which run_report warns and asks for explicit opt-in. */ | ||
@@ -33,2 +41,38 @@ export const DEFAULT_ROW_WARNING_THRESHOLD = 2500; | ||
| } | ||
| /** | ||
| * Runtime shape of the Data API runReport / runPivotReport / runRealtimeReport | ||
| * responses (and reportTasks:query, which returns the same row payload). | ||
| * Validated at the boundary (fail-closed) instead of only TypeScript-cast; | ||
| * .passthrough() keeps the surface forward-compatible with new vendor fields. | ||
| * pivotHeaders / propertyQuota stay opaque: pivot headers are enveloped | ||
| * wholesale downstream and quota snapshots are vendor-numeric structures. | ||
| */ | ||
| const dataApiHeaderSchema = z.object({ name: z.string().optional() }).passthrough(); | ||
| const dataApiValueSchema = z.object({ value: z.string().optional() }).passthrough(); | ||
| export const dataApiResponseSchema = z | ||
| .object({ | ||
| rowCount: z.number().optional(), | ||
| dimensionHeaders: z.array(dataApiHeaderSchema).optional(), | ||
| metricHeaders: z.array(dataApiHeaderSchema).optional(), | ||
| rows: z | ||
| .array(z | ||
| .object({ | ||
| dimensionValues: z.array(dataApiValueSchema).optional(), | ||
| metricValues: z.array(dataApiValueSchema).optional(), | ||
| }) | ||
| .passthrough()) | ||
| .optional(), | ||
| totals: z | ||
| .array(z.object({ metricValues: z.array(dataApiValueSchema).optional() }).passthrough()) | ||
| .optional(), | ||
| maximums: z | ||
| .array(z.object({ metricValues: z.array(dataApiValueSchema).optional() }).passthrough()) | ||
| .optional(), | ||
| minimums: z | ||
| .array(z.object({ metricValues: z.array(dataApiValueSchema).optional() }).passthrough()) | ||
| .optional(), | ||
| pivotHeaders: z.array(z.unknown()).optional(), | ||
| propertyQuota: z.unknown().optional(), | ||
| }) | ||
| .passthrough(); | ||
| //# sourceMappingURL=types.js.map |
+66
-15
| import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { z } from 'zod'; | ||
| import { type DataApiResponse } from './types.js'; | ||
| /** Envelope source labels used across the connector. */ | ||
| export declare const UNTRUSTED_SOURCES: { | ||
| readonly report: "ga4-report"; | ||
| readonly admin: "ga4-admin"; | ||
| readonly metadata: "ga4-metadata"; | ||
| readonly audienceExport: "ga4-audience-export"; | ||
| readonly apiError: "ga4-api-error"; | ||
| }; | ||
| type ToolHandler<T> = (args: T, extra: unknown) => Promise<CallToolResult>; | ||
@@ -12,2 +21,14 @@ /** | ||
| export declare function toNameList(value: unknown): string[]; | ||
| /** | ||
| * Google returns int64 fields as either JSON numbers or strings depending on | ||
| * the surface — accept both and pass the value through unchanged. | ||
| */ | ||
| export declare const int64Field: z.ZodUnion<[z.ZodString, z.ZodNumber]>; | ||
| /** | ||
| * Validate an external API response body against a Zod schema. Google API | ||
| * responses are otherwise only TypeScript-cast; a shape mismatch must fail | ||
| * closed with a structured error rather than propagate garbage downstream. | ||
| * Schemas use .passthrough() so new vendor fields stay forward-compatible. | ||
| */ | ||
| export declare function parseApiResponse<T>(schema: z.ZodType<T>, data: unknown, context: string): T; | ||
| /** Strip undefined values so the request body stays compact. */ | ||
@@ -53,15 +74,45 @@ export declare function compactObject<T extends Record<string, unknown>>(obj: T): Partial<T>; | ||
| }, kind: 'dimension' | 'metric'): string; | ||
| interface MetadataField { | ||
| apiName?: string; | ||
| uiName?: string; | ||
| description?: string; | ||
| category?: string; | ||
| type?: string; | ||
| expression?: string; | ||
| customDefinition?: boolean; | ||
| deprecatedApiNames?: string[]; | ||
| allowedInSegments?: boolean; | ||
| dimensionCompatibleMetrics?: unknown; | ||
| metricCompatibleDimensions?: unknown; | ||
| } | ||
| /** | ||
| * Runtime shape of a Data-API metadata field. Validated at the boundary | ||
| * (fail-closed) instead of only TypeScript-cast; .passthrough() keeps the | ||
| * surface forward-compatible with new vendor fields. | ||
| */ | ||
| export declare const metadataFieldSchema: z.ZodObject<{ | ||
| apiName: z.ZodOptional<z.ZodString>; | ||
| uiName: z.ZodOptional<z.ZodString>; | ||
| description: z.ZodOptional<z.ZodString>; | ||
| category: z.ZodOptional<z.ZodString>; | ||
| type: z.ZodOptional<z.ZodString>; | ||
| expression: z.ZodOptional<z.ZodString>; | ||
| customDefinition: z.ZodOptional<z.ZodBoolean>; | ||
| deprecatedApiNames: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| allowedInSegments: z.ZodOptional<z.ZodBoolean>; | ||
| dimensionCompatibleMetrics: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| metricCompatibleDimensions: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ | ||
| apiName: z.ZodOptional<z.ZodString>; | ||
| uiName: z.ZodOptional<z.ZodString>; | ||
| description: z.ZodOptional<z.ZodString>; | ||
| category: z.ZodOptional<z.ZodString>; | ||
| type: z.ZodOptional<z.ZodString>; | ||
| expression: z.ZodOptional<z.ZodString>; | ||
| customDefinition: z.ZodOptional<z.ZodBoolean>; | ||
| deprecatedApiNames: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| allowedInSegments: z.ZodOptional<z.ZodBoolean>; | ||
| dimensionCompatibleMetrics: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| metricCompatibleDimensions: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ | ||
| apiName: z.ZodOptional<z.ZodString>; | ||
| uiName: z.ZodOptional<z.ZodString>; | ||
| description: z.ZodOptional<z.ZodString>; | ||
| category: z.ZodOptional<z.ZodString>; | ||
| type: z.ZodOptional<z.ZodString>; | ||
| expression: z.ZodOptional<z.ZodString>; | ||
| customDefinition: z.ZodOptional<z.ZodBoolean>; | ||
| deprecatedApiNames: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| allowedInSegments: z.ZodOptional<z.ZodBoolean>; | ||
| dimensionCompatibleMetrics: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| metricCompatibleDimensions: z.ZodOptional<z.ZodArray<z.ZodString, "many">>; | ||
| }, z.ZodTypeAny, "passthrough">>; | ||
| type MetadataField = z.infer<typeof metadataFieldSchema>; | ||
| /** Map a raw Data-API metadata field into a cleaner shape. */ | ||
@@ -78,6 +129,6 @@ export declare function mapMetadataField(field: MetadataField, kind: 'dimension' | 'metric'): { | ||
| allowedInSegments: boolean; | ||
| dimensionCompatibleMetrics: {} | undefined; | ||
| metricCompatibleDimensions: {} | undefined; | ||
| dimensionCompatibleMetrics: string[] | undefined; | ||
| metricCompatibleDimensions: string[] | undefined; | ||
| }; | ||
| export {}; | ||
| //# sourceMappingURL=utils.d.ts.map |
+139
-9
@@ -0,2 +1,12 @@ | ||
| import { z, ZodError } from 'zod'; | ||
| import { GoogleAnalyticsError } from './types.js'; | ||
| import { wrapUntrusted, wrapUntrustedJsonStrings } from './untrusted-content.js'; | ||
| /** Envelope source labels used across the connector. */ | ||
| export const UNTRUSTED_SOURCES = { | ||
| report: 'ga4-report', | ||
| admin: 'ga4-admin', | ||
| metadata: 'ga4-metadata', | ||
| audienceExport: 'ga4-audience-export', | ||
| apiError: 'ga4-api-error', | ||
| }; | ||
| /** | ||
@@ -30,2 +40,20 @@ * Wraps a tool handler with standard error handling. | ||
| } | ||
| if (error instanceof ZodError) { | ||
| // Connector-generated validation detail (never vendor text) — safe to | ||
| // surface so the caller can correct the arguments. | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| ok: false, | ||
| error: 'Invalid tool arguments.', | ||
| code: 'INVALID_ARGUMENTS', | ||
| issues: error.issues, | ||
| }), | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
@@ -40,4 +68,19 @@ // Map common google-auth-library errors to friendly messages. | ||
| } | ||
| // Unexpected runtime errors can embed credential details, file paths, or | ||
| // vendor/proxy-controlled fragments from deep library stacks. Log the | ||
| // detail to server stderr (not model-visible) and return a sanitised | ||
| // message instead of the raw error text. | ||
| console.error('[google-analytics] Unexpected error:', error); | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify({ ok: false, error: errorMessage }) }], | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| ok: false, | ||
| error: 'An unexpected error occurred while calling the Google Analytics API.', | ||
| code: 'UNEXPECTED_ERROR', | ||
| resolution: 'Try again. If the problem persists, check the MCP host server logs for the underlying error detail.', | ||
| }), | ||
| }, | ||
| ], | ||
| isError: true, | ||
@@ -91,2 +134,20 @@ }; | ||
| } | ||
| /** | ||
| * Google returns int64 fields as either JSON numbers or strings depending on | ||
| * the surface — accept both and pass the value through unchanged. | ||
| */ | ||
| export const int64Field = z.union([z.string(), z.number()]); | ||
| /** | ||
| * Validate an external API response body against a Zod schema. Google API | ||
| * responses are otherwise only TypeScript-cast; a shape mismatch must fail | ||
| * closed with a structured error rather than propagate garbage downstream. | ||
| * Schemas use .passthrough() so new vendor fields stay forward-compatible. | ||
| */ | ||
| export function parseApiResponse(schema, data, context) { | ||
| const result = schema.safeParse(data); | ||
| if (!result.success) { | ||
| throw new GoogleAnalyticsError(`Google API returned an unexpected response shape for ${context}.`, 'INVALID_API_RESPONSE', 'Try again. If the problem persists, the API response format may have changed — check for a connector update.'); | ||
| } | ||
| return result.data; | ||
| } | ||
| /** Strip undefined values so the request body stays compact. */ | ||
@@ -123,8 +184,29 @@ export function compactObject(obj) { | ||
| export function formatRows(response) { | ||
| const dimensionHeaders = (response.dimensionHeaders || []).map((h) => h.name); | ||
| const metricHeaders = (response.metricHeaders || []).map((h) => h.name); | ||
| // Header names are vendor-echoed strings (custom dimension/metric names are | ||
| // authored by property editors) and become structural keys in the output — | ||
| // envelope them the same way the recursive helper envelopes object keys | ||
| // (invariant #6). | ||
| // | ||
| // Header names are not unique by contract: two headers can both lack a | ||
| // name (both fall back to 'unknown'), or a dimension and a metric can | ||
| // share a name. Disambiguate duplicates so a later column never silently | ||
| // overwrites an earlier one in the row objects; the header arrays carry | ||
| // the same deduplicated names so keys stay traceable. | ||
| const usedHeaderNames = new Map(); | ||
| const uniqueHeader = (name) => { | ||
| const seen = usedHeaderNames.get(name) ?? 0; | ||
| usedHeaderNames.set(name, seen + 1); | ||
| return seen === 0 ? name : `${name} (${seen + 1})`; | ||
| }; | ||
| const dimensionHeaders = (response.dimensionHeaders || []).map((h) => uniqueHeader(wrapUntrusted(h.name, UNTRUSTED_SOURCES.report) ?? 'unknown')); | ||
| const metricHeaders = (response.metricHeaders || []).map((h) => uniqueHeader(wrapUntrusted(h.name, UNTRUSTED_SOURCES.report) ?? 'unknown')); | ||
| const rows = (response.rows || []).map((row) => { | ||
| const item = {}; | ||
| dimensionHeaders.forEach((header, index) => { | ||
| item[header] = row.dimensionValues?.[index]?.value ?? null; | ||
| // Dimension values (page titles, campaign names, custom-dimension | ||
| // values) are authored or influenced outside Google's control and | ||
| // rendered to the model — envelope them (invariant #6). Metric values | ||
| // are numeric strings and stay raw. | ||
| item[header] = | ||
| wrapUntrusted(row.dimensionValues?.[index]?.value, UNTRUSTED_SOURCES.report) ?? null; | ||
| }); | ||
@@ -216,18 +298,66 @@ metricHeaders.forEach((header, index) => { | ||
| } | ||
| /** | ||
| * Runtime shape of a Data-API metadata field. Validated at the boundary | ||
| * (fail-closed) instead of only TypeScript-cast; .passthrough() keeps the | ||
| * surface forward-compatible with new vendor fields. | ||
| */ | ||
| export const metadataFieldSchema = z | ||
| .object({ | ||
| apiName: z.string().optional(), | ||
| uiName: z.string().optional(), | ||
| description: z.string().optional(), | ||
| category: z.string().optional(), | ||
| type: z.string().optional(), | ||
| expression: z.string().optional(), | ||
| customDefinition: z.boolean().optional(), | ||
| deprecatedApiNames: z.array(z.string()).optional(), | ||
| allowedInSegments: z.boolean().optional(), | ||
| dimensionCompatibleMetrics: z.array(z.string()).optional(), | ||
| metricCompatibleDimensions: z.array(z.string()).optional(), | ||
| }) | ||
| .passthrough(); | ||
| /** | ||
| * apiName prefixes that always identify property-editor-authored definitions | ||
| * (custom dimensions/metrics and calculated metrics), independent of the | ||
| * vendor-supplied customDefinition flag. | ||
| */ | ||
| const CUSTOM_API_NAME_PREFIX = /^(customUser|customItem|customEvent|calculatedMetric):/; | ||
| /** Map a raw Data-API metadata field into a cleaner shape. */ | ||
| export function mapMetadataField(field, kind) { | ||
| // Standard dimension/metric uiName/description are Google-authored | ||
| // documentation. Custom definitions are authored by property editors, so | ||
| // only those are enveloped (invariant #6). Google labels custom fields with | ||
| // customDefinition: true, but that label is itself vendor-controlled — a | ||
| // recognised custom apiName prefix is treated as user-authored even when | ||
| // the flag is absent, so the gate cannot fail open on an unlabeled custom | ||
| // field. | ||
| const userAuthored = field.customDefinition === true || CUSTOM_API_NAME_PREFIX.test(field.apiName ?? ''); | ||
| return { | ||
| apiName: field.apiName || null, | ||
| uiName: field.uiName || null, | ||
| description: field.description || null, | ||
| uiName: userAuthored | ||
| ? wrapUntrusted(field.uiName, UNTRUSTED_SOURCES.metadata) ?? null | ||
| : field.uiName || null, | ||
| description: userAuthored | ||
| ? wrapUntrusted(field.description, UNTRUSTED_SOURCES.metadata) ?? null | ||
| : field.description || null, | ||
| category: categoriseField(field, kind), | ||
| type: field.type || null, | ||
| expression: field.expression || null, | ||
| // The expression of a custom calculated metric is property-editor-authored | ||
| // — envelope it under the same gate as uiName/description (invariant #6). | ||
| expression: userAuthored | ||
| ? wrapUntrusted(field.expression, UNTRUSTED_SOURCES.metadata) ?? null | ||
| : field.expression || null, | ||
| customDefinition: field.customDefinition || false, | ||
| deprecatedApiNames: field.deprecatedApiNames || [], | ||
| allowedInSegments: field.allowedInSegments || false, | ||
| dimensionCompatibleMetrics: field.dimensionCompatibleMetrics || undefined, | ||
| metricCompatibleDimensions: field.metricCompatibleDimensions || undefined, | ||
| // Vendor-echoed field-name lists — envelope before model output | ||
| // (invariant #6). | ||
| dimensionCompatibleMetrics: field.dimensionCompatibleMetrics | ||
| ? wrapUntrustedJsonStrings(field.dimensionCompatibleMetrics, UNTRUSTED_SOURCES.metadata) | ||
| : undefined, | ||
| metricCompatibleDimensions: field.metricCompatibleDimensions | ||
| ? wrapUntrustedJsonStrings(field.metricCompatibleDimensions, UNTRUSTED_SOURCES.metadata) | ||
| : undefined, | ||
| }; | ||
| } | ||
| //# sourceMappingURL=utils.js.map |
+1
-1
| { | ||
| "name": "@mindstone/mcp-server-google-analytics", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "mcpName": "io.github.mindstone/mcp-server-google-analytics", | ||
@@ -5,0 +5,0 @@ "description": "Google Analytics 4 MCP server with reporting, schema discovery, and admin visibility tools", |
+60
-2
@@ -6,4 +6,12 @@ # @mindstone/mcp-server-google-analytics | ||
| Google Analytics 4 MCP server for Model Context Protocol hosts. Discover account/property structure, explore the live schema, run reports (with row-volume safety), and inspect admin configuration through a standardised MCP interface. | ||
| Google Analytics 4 MCP server for Model Context Protocol hosts. Discover account/property structure, explore the live schema, run reports (with row-volume safety), create large asynchronous exports, and inspect admin configuration through a standardised MCP interface. | ||
| ## Status | ||
| - **Version:** [0.2.0](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-google-analytics) | ||
| - **Auth:** OAuth via Google ADC ([`GOOGLE_APPLICATION_CREDENTIALS`](./server.json)) | ||
| - **Tools:** [34](./src/tools/) (accounts, schema, reporting, admin) | ||
| - **Surface:** cloud-api | ||
| - **Machine-readable:** [`STATUS.json`](./STATUS.json) | ||
| ## Requirements | ||
@@ -15,2 +23,34 @@ | ||
| <!-- BEGIN INSTALL_LINKS: do not edit by hand; regenerated by scripts/gen-install-links.mjs --> | ||
| ## One-click install | ||
| [](cursor://anysphere.cursor-deeplink/mcp/install?name=Google%20Analytics%204&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtaW5kc3RvbmUvbWNwLXNlcnZlci1nb29nbGUtYW5hbHl0aWNzIl0sImVudiI6eyJHT09HTEVfQVBQTElDQVRJT05fQ1JFREVOVElBTFMiOiIifX0) | ||
| [](vscode:mcp/install?%7B%22name%22%3A%22Google%20Analytics%204%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40mindstone%2Fmcp-server-google-analytics%22%5D%2C%22env%22%3A%7B%22GOOGLE_APPLICATION_CREDENTIALS%22%3A%22%22%7D%7D) | ||
| [](vscode-insiders:mcp/install?%7B%22name%22%3A%22Google%20Analytics%204%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40mindstone%2Fmcp-server-google-analytics%22%5D%2C%22env%22%3A%7B%22GOOGLE_APPLICATION_CREDENTIALS%22%3A%22%22%7D%7D) | ||
| After clicking the button, your host will prompt you to fill: `GOOGLE_APPLICATION_CREDENTIALS`. | ||
| <details> | ||
| <summary>Manual config for Claude Desktop / Claude Code / Goose / Continue.dev (Google Analytics 4)</summary> | ||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "Google Analytics 4": { | ||
| "command": "npx", | ||
| "args": [ | ||
| "-y", | ||
| "@mindstone/mcp-server-google-analytics" | ||
| ], | ||
| "env": { | ||
| "GOOGLE_APPLICATION_CREDENTIALS": "" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| </details> | ||
| <!-- END INSTALL_LINKS --> | ||
| ## Quick Start | ||
@@ -95,3 +135,3 @@ | ||
| ## Tools (25) | ||
| ## Tools (34) | ||
@@ -118,3 +158,14 @@ ### Account & property | ||
| ### Large exports | ||
| - `ga_create_report_task` — start an asynchronous report task for large exports (no synchronous timeout, no row-volume gate) | ||
| - `ga_get_report_task` — poll task state until `ACTIVE` | ||
| - `ga_query_report_task` — page task rows (up to 250,000 per page) | ||
| - `ga_create_audience_export` — snapshot the users in an audience (incl. predictive segments); charges audience-export quota tokens | ||
| - `ga_get_audience_export` — poll export state until `ACTIVE` | ||
| - `ga_list_audience_exports` — find and reuse existing exports | ||
| - `ga_query_audience_export` — page user-level rows from an `ACTIVE` export | ||
| ### Admin visibility | ||
| - `ga_list_audiences` — audiences configured on the property, with filter clauses | ||
| - `ga_list_channel_groups` — channel groups and their grouping rules | ||
| - `ga_get_custom_dimensions_and_metrics` | ||
@@ -130,4 +181,11 @@ - `ga_list_google_ads_links` | ||
| ## Notes | ||
| - **Read-only posture.** All tools are read-only except `ga_create_report_task` and `ga_create_audience_export`, which materialise server-side snapshots and charge quota (annotated `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`) without modifying property configuration. Hosts can gate the two creation tools behind explicit user approval. | ||
| - **Alpha endpoints.** Audiences, channel groups, BigQuery links, the global site tag, change history (Admin API), and report tasks (Data API) are only exposed on Google's `v1alpha` surfaces today; the corresponding tools note this in their descriptions. | ||
| - **Untrusted content.** Text authored inside the GA4 property — report dimension values (page titles, campaign names, custom-dimension values), audience/display names, descriptions, definition blobs, data-stream stream-data blobs, the global site tag snippet, custom-metadata expressions, vendor-echoed header/dimension names, and vendor error messages — is returned inside `<untrusted-content source="…">` envelopes so hosts treat it as data, not instructions. Metric values and resource identifiers stay raw so agents can compose follow-up calls. Resource IDs passed to tools are constrained to the ID charset before URL interpolation (`INVALID_RESOURCE_ID` on anything else). | ||
| - **Response validation.** Every Google API response is validated against a Zod schema at the boundary; a shape mismatch fails closed with an `INVALID_API_RESPONSE` error rather than propagating malformed data. Paginated lists follow `nextPageToken` in full and fail with `PAGINATION_LIMIT_EXCEEDED` if the API does not stop paging after a generous safety cap — never a silent truncation. | ||
| ## Licence | ||
| [FSL-1.1-MIT](./LICENSE) — Functional Source License, Version 1.1, with MIT future licence. The software converts to MIT licence on the second anniversary of release. |
Unidentified License
LicenseSomething that seems like a license was found, but its contents could not be matched with a known license.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Unidentified License
LicenseSomething that seems like a license was found, but its contents could not be matched with a known license.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
177209
122.03%33
32%3545
121.01%187
44.96%