🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@mindstone/mcp-server-google-analytics

Package Overview
Dependencies
Maintainers
1
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mindstone/mcp-server-google-analytics - npm Package Compare versions

Comparing version
0.1.1
to
0.2.0
+173
dist/filters.d.ts
/**
* 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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
/**
* 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,

@@ -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

@@ -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,

@@ -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,

/**
* 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
/**
* 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
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

@@ -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
{
"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",

@@ -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
[![Add to Cursor](https://img.shields.io/badge/Add_to_Cursor-black?style=for-the-badge&logo=cursor&logoColor=white)](cursor://anysphere.cursor-deeplink/mcp/install?name=Google%20Analytics%204&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBtaW5kc3RvbmUvbWNwLXNlcnZlci1nb29nbGUtYW5hbHl0aWNzIl0sImVudiI6eyJHT09HTEVfQVBQTElDQVRJT05fQ1JFREVOVElBTFMiOiIifX0)
[![Add to VS Code](https://img.shields.io/badge/Add_to_VS_Code-007ACC?style=for-the-badge&logo=visual-studio-code&logoColor=white)](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)
[![Add to VS Code Insiders](https://img.shields.io/badge/Add_to_VS_Code_Insiders-24bfa5?style=for-the-badge&logo=visual-studio-code&logoColor=white)](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.