mcp-ga4 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # mcp-ga4
2
+
3
+ Ask Google Analytics 4 questions in plain English using Claude.
4
+
5
+ ## Quickstart (2 steps)
6
+
7
+ ### 1. Setup
8
+
9
+ ```bash
10
+ npx mcp-ga4-setup
11
+ ```
12
+
13
+ This will:
14
+ - Ask for your GA4 property ID (find it in GA4 > Admin > Property Details)
15
+ - Open your browser to sign in with Google
16
+ - Verify the connection works
17
+ - Generate the Claude Code configuration snippet
18
+
19
+ **Note:** You may see a "Google hasn't verified this app" warning during sign-in.
20
+ Click **Advanced** then **Go to mcp-ga4 (unsafe)** to continue. This is safe --
21
+ the app only requests read-only access to your analytics data.
22
+
23
+ ### 2. Configure Claude Code
24
+
25
+ Copy the JSON snippet from the setup wizard into your Claude Code MCP config.
26
+
27
+ The snippet looks like:
28
+
29
+ ```json
30
+ {
31
+ "ga4": {
32
+ "command": "npx",
33
+ "args": ["-y", "mcp-ga4"],
34
+ "env": {
35
+ "GA4_PROPERTY_ID": "YOUR_PROPERTY_ID",
36
+ "GOOGLE_APPLICATION_CREDENTIALS": "~/.config/mcp-ga4/credentials.json"
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ - **Claude Desktop (Mac):** `~/Library/Application Support/Claude/claude_desktop_config.json`
43
+ - **Claude Code CLI:** `.mcp.json` in your project directory
44
+
45
+ That's it. No Python, no pip, no venv. Just `npx`.
46
+
47
+ ## Usage
48
+
49
+ Once configured, just ask Claude questions about your analytics:
50
+
51
+ - "What were my top 10 pages last week?"
52
+ - "Show me traffic by source for the past 30 days"
53
+ - "What's my bounce rate trend this month?"
54
+ - "Which campaigns drove the most conversions?"
55
+ - "What devices are my users on?"
56
+ - "Show me real-time active users"
57
+
58
+ ## Feedback
59
+
60
+ If a query doesn't return what you expected, tell Claude! It will log the
61
+ pattern using the built-in `ga4_suggest_improvement` tool so the tool can
62
+ be improved for everyone.
63
+
64
+ You can also say "send feedback" to Claude to report bugs or request features.
65
+
66
+ ## Troubleshooting
67
+
68
+ **"No refresh token received"**
69
+ Go to https://myaccount.google.com/permissions, remove "mcp-ga4", then re-run `npx mcp-ga4-setup`.
70
+
71
+ **"Connection failed" / "Permission denied"**
72
+ Your Google account may not have access to the GA4 property. Check your access at
73
+ GA4 > Admin > Account Access Management.
74
+
75
+ **"OAuth client credentials not found"**
76
+ The setup needs OAuth client credentials. Set `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET`
77
+ environment variables, or on macOS store them in Keychain (the setup will guide you).
78
+
79
+ **Token expired**
80
+ Re-run `npx mcp-ga4-setup` to refresh your credentials.
81
+
82
+ ## Advanced: Multi-Property Setup
83
+
84
+ For managing multiple GA4 properties, create `~/.config/mcp-ga4/config.json`:
85
+
86
+ ```json
87
+ {
88
+ "credentials_file": "~/.config/mcp-ga4/credentials.json",
89
+ "clients": {
90
+ "my-site": {
91
+ "name": "My Website",
92
+ "folder": "/path/to/project",
93
+ "property_id": "123456789"
94
+ },
95
+ "other-site": {
96
+ "name": "Other Site",
97
+ "folder": "/path/to/other",
98
+ "property_id": "987654321"
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * OAuth credential loading and token refresh for GA4 APIs.
3
+ *
4
+ * Supports two credential types:
5
+ * 1. authorized_user -- OAuth with refresh token (from mcp-ga4-setup)
6
+ * 2. service_account -- Service account JSON key file
7
+ */
8
+ interface AuthorizedUserCreds {
9
+ type: "authorized_user";
10
+ client_id: string;
11
+ client_secret: string;
12
+ refresh_token: string;
13
+ }
14
+ interface ServiceAccountCreds {
15
+ type: "service_account";
16
+ client_email: string;
17
+ private_key: string;
18
+ token_uri: string;
19
+ }
20
+ type CredentialFile = AuthorizedUserCreds | ServiceAccountCreds;
21
+ export declare function loadCredentials(credentialsFile: string): CredentialFile;
22
+ /**
23
+ * Get a valid access token, refreshing if needed.
24
+ */
25
+ export declare function getAccessToken(credentialsFile: string): Promise<string>;
26
+ /**
27
+ * Validate credentials exist and are readable. Returns credential type info.
28
+ */
29
+ export declare function validateCredentials(credentialsFile: string): {
30
+ valid: boolean;
31
+ type?: string;
32
+ error?: string;
33
+ };
34
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * OAuth credential loading and token refresh for GA4 APIs.
3
+ *
4
+ * Supports two credential types:
5
+ * 1. authorized_user -- OAuth with refresh token (from mcp-ga4-setup)
6
+ * 2. service_account -- Service account JSON key file
7
+ */
8
+ import { readFileSync } from "fs";
9
+ import { logger } from "./resilience.js";
10
+ let cachedAccessToken = null;
11
+ let tokenExpiry = 0;
12
+ let cachedCreds = null;
13
+ export function loadCredentials(credentialsFile) {
14
+ if (cachedCreds)
15
+ return cachedCreds;
16
+ const raw = readFileSync(credentialsFile, "utf-8");
17
+ cachedCreds = JSON.parse(raw);
18
+ return cachedCreds;
19
+ }
20
+ /**
21
+ * Get a valid access token, refreshing if needed.
22
+ */
23
+ export async function getAccessToken(credentialsFile) {
24
+ if (cachedAccessToken && Date.now() < tokenExpiry) {
25
+ return cachedAccessToken;
26
+ }
27
+ const creds = loadCredentials(credentialsFile);
28
+ if (creds.type === "authorized_user") {
29
+ return refreshOAuthToken(creds);
30
+ }
31
+ else if (creds.type === "service_account") {
32
+ return getServiceAccountToken(creds);
33
+ }
34
+ throw new Error(`Unsupported credential type: ${creds.type}`);
35
+ }
36
+ async function refreshOAuthToken(creds) {
37
+ const params = new URLSearchParams({
38
+ grant_type: "refresh_token",
39
+ client_id: creds.client_id,
40
+ client_secret: creds.client_secret,
41
+ refresh_token: creds.refresh_token,
42
+ });
43
+ const resp = await fetch("https://oauth2.googleapis.com/token", {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
46
+ body: params.toString(),
47
+ });
48
+ if (!resp.ok) {
49
+ const text = await resp.text();
50
+ throw new Error(`OAuth token refresh failed (${resp.status}): ${text}`);
51
+ }
52
+ const data = (await resp.json());
53
+ cachedAccessToken = data.access_token;
54
+ tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
55
+ logger.debug("OAuth token refreshed");
56
+ return cachedAccessToken;
57
+ }
58
+ async function getServiceAccountToken(creds) {
59
+ // Build JWT for service account
60
+ const now = Math.floor(Date.now() / 1000);
61
+ const header = { alg: "RS256", typ: "JWT" };
62
+ const payload = {
63
+ iss: creds.client_email,
64
+ scope: "https://www.googleapis.com/auth/analytics.readonly https://www.googleapis.com/auth/analytics.edit",
65
+ aud: creds.token_uri || "https://oauth2.googleapis.com/token",
66
+ iat: now,
67
+ exp: now + 3600,
68
+ };
69
+ const { createSign } = await import("crypto");
70
+ const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
71
+ const unsigned = `${encode(header)}.${encode(payload)}`;
72
+ const sign = createSign("RSA-SHA256");
73
+ sign.update(unsigned);
74
+ const signature = sign.sign(creds.private_key, "base64url");
75
+ const jwt = `${unsigned}.${signature}`;
76
+ const params = new URLSearchParams({
77
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
78
+ assertion: jwt,
79
+ });
80
+ const resp = await fetch(creds.token_uri || "https://oauth2.googleapis.com/token", {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
83
+ body: params.toString(),
84
+ });
85
+ if (!resp.ok) {
86
+ const text = await resp.text();
87
+ throw new Error(`Service account token exchange failed (${resp.status}): ${text}`);
88
+ }
89
+ const data = (await resp.json());
90
+ cachedAccessToken = data.access_token;
91
+ tokenExpiry = Date.now() + (data.expires_in - 60) * 1000;
92
+ logger.debug({ serviceAccount: creds.client_email }, "Service account token obtained");
93
+ return cachedAccessToken;
94
+ }
95
+ /**
96
+ * Validate credentials exist and are readable. Returns credential type info.
97
+ */
98
+ export function validateCredentials(credentialsFile) {
99
+ if (!credentialsFile) {
100
+ return {
101
+ valid: false,
102
+ error: "No credentials file specified (GOOGLE_APPLICATION_CREDENTIALS)",
103
+ };
104
+ }
105
+ try {
106
+ const creds = loadCredentials(credentialsFile);
107
+ if (creds.type === "authorized_user") {
108
+ if (!creds.refresh_token) {
109
+ return { valid: false, error: "Missing refresh_token in credentials" };
110
+ }
111
+ return { valid: true, type: "authorized_user" };
112
+ }
113
+ else if (creds.type === "service_account") {
114
+ if (!creds.private_key || !creds.client_email) {
115
+ return {
116
+ valid: false,
117
+ error: "Missing private_key or client_email in service account",
118
+ };
119
+ }
120
+ return {
121
+ valid: true,
122
+ type: `service_account (${creds.client_email})`,
123
+ };
124
+ }
125
+ return { valid: false, error: `Unknown credential type: ${creds.type}` };
126
+ }
127
+ catch (err) {
128
+ return { valid: false, error: err.message };
129
+ }
130
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Typed errors and classification for GA4 API calls.
3
+ */
4
+ export declare class GA4AuthError extends Error {
5
+ readonly cause?: unknown | undefined;
6
+ constructor(message: string, cause?: unknown | undefined);
7
+ }
8
+ export declare class GA4RateLimitError extends Error {
9
+ readonly retryAfterMs: number;
10
+ constructor(retryAfterMs: number, cause?: unknown);
11
+ }
12
+ export declare class GA4ServiceError extends Error {
13
+ readonly cause?: unknown | undefined;
14
+ constructor(message: string, cause?: unknown | undefined);
15
+ }
16
+ export declare function classifyError(error: any): Error;
package/dist/errors.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Typed errors and classification for GA4 API calls.
3
+ */
4
+ export class GA4AuthError extends Error {
5
+ cause;
6
+ constructor(message, cause) {
7
+ super(message);
8
+ this.cause = cause;
9
+ this.name = "GA4AuthError";
10
+ }
11
+ }
12
+ export class GA4RateLimitError extends Error {
13
+ retryAfterMs;
14
+ constructor(retryAfterMs, cause) {
15
+ super(`Rate limited, retry after ${retryAfterMs}ms`);
16
+ this.name = "GA4RateLimitError";
17
+ this.retryAfterMs = retryAfterMs;
18
+ this.cause = cause;
19
+ }
20
+ }
21
+ export class GA4ServiceError extends Error {
22
+ cause;
23
+ constructor(message, cause) {
24
+ super(message);
25
+ this.cause = cause;
26
+ this.name = "GA4ServiceError";
27
+ }
28
+ }
29
+ export function classifyError(error) {
30
+ const message = typeof error?.message === "string" ? error.message : String(error);
31
+ const status = error?.status || error?.code;
32
+ // Auth failures
33
+ if (status === 401 ||
34
+ status === 403 ||
35
+ message.includes("invalid_grant") ||
36
+ message.includes("Token has been expired") ||
37
+ message.includes("refresh token") ||
38
+ message.includes("UNAUTHENTICATED") ||
39
+ message.includes("PERMISSION_DENIED")) {
40
+ return new GA4AuthError(`Auth failed: ${message}. Re-run mcp-ga4-setup to refresh credentials.`, error);
41
+ }
42
+ // Rate limiting
43
+ if (status === 429 || message.includes("RESOURCE_EXHAUSTED")) {
44
+ const retryMs = 60_000;
45
+ return new GA4RateLimitError(retryMs, error);
46
+ }
47
+ // Server errors
48
+ if ((typeof status === "number" && status >= 500) ||
49
+ message.includes("INTERNAL")) {
50
+ return new GA4ServiceError(`GA4 API server error: ${message}`, error);
51
+ }
52
+ if (!(error instanceof Error)) {
53
+ const wrapped = new Error(message);
54
+ wrapped.name = "GA4Error";
55
+ wrapped.cause = error;
56
+ return wrapped;
57
+ }
58
+ return error;
59
+ }
package/dist/ga4.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * GA4 REST API client -- Data API + Admin API.
3
+ *
4
+ * Uses direct REST calls instead of heavy client libraries
5
+ * to keep the package lightweight for distribution.
6
+ */
7
+ export declare class GA4Manager {
8
+ private credentialsFile;
9
+ constructor(credentialsFile: string);
10
+ private request;
11
+ runReport(propertyId: string, dimensions: string[], metrics: string[], startDate: string, endDate: string, dimensionFilter?: {
12
+ field: string;
13
+ value: string;
14
+ }, limit?: number, orderBy?: string): Promise<any>;
15
+ runRealtimeReport(propertyId: string, dimensions: string[], metrics: string[], dimensionFilter?: {
16
+ field: string;
17
+ value: string;
18
+ }): Promise<any>;
19
+ listDataStreams(propertyId: string): Promise<any>;
20
+ listCustomDimensions(propertyId: string): Promise<any>;
21
+ createCustomDimension(propertyId: string, parameterName: string, displayName: string, scope: string, description: string): Promise<any>;
22
+ listCustomMetrics(propertyId: string): Promise<any>;
23
+ }
package/dist/ga4.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * GA4 REST API client -- Data API + Admin API.
3
+ *
4
+ * Uses direct REST calls instead of heavy client libraries
5
+ * to keep the package lightweight for distribution.
6
+ */
7
+ import { getAccessToken } from "./auth.js";
8
+ import { withResilience, safeResponse } from "./resilience.js";
9
+ import { classifyError } from "./errors.js";
10
+ const DATA_API = "https://analyticsdata.googleapis.com/v1beta";
11
+ const ADMIN_API = "https://analyticsadmin.googleapis.com/v1beta";
12
+ export class GA4Manager {
13
+ credentialsFile;
14
+ constructor(credentialsFile) {
15
+ this.credentialsFile = credentialsFile;
16
+ }
17
+ async request(url, method = "GET", body) {
18
+ const token = await getAccessToken(this.credentialsFile);
19
+ const options = {
20
+ method,
21
+ headers: {
22
+ Authorization: `Bearer ${token}`,
23
+ "Content-Type": "application/json",
24
+ },
25
+ };
26
+ if (body) {
27
+ options.body = JSON.stringify(body);
28
+ }
29
+ const resp = await fetch(url, options);
30
+ if (!resp.ok) {
31
+ const text = await resp.text();
32
+ const err = new Error(`GA4 API ${resp.status}: ${text}`);
33
+ err.status = resp.status;
34
+ throw classifyError(err);
35
+ }
36
+ return resp.json();
37
+ }
38
+ // ============================================
39
+ // DATA API
40
+ // ============================================
41
+ async runReport(propertyId, dimensions, metrics, startDate, endDate, dimensionFilter, limit = 100, orderBy) {
42
+ const body = {
43
+ dimensions: dimensions.map((d) => ({ name: d })),
44
+ metrics: metrics.map((m) => ({ name: m })),
45
+ dateRanges: [{ startDate, endDate }],
46
+ limit,
47
+ };
48
+ if (dimensionFilter) {
49
+ body.dimensionFilter = {
50
+ filter: {
51
+ fieldName: dimensionFilter.field,
52
+ stringFilter: { value: dimensionFilter.value },
53
+ },
54
+ };
55
+ }
56
+ if (orderBy) {
57
+ body.orderBys = [
58
+ { metric: { metricName: orderBy }, desc: true },
59
+ ];
60
+ }
61
+ const data = await withResilience(() => this.request(`${DATA_API}/properties/${propertyId}:runReport`, "POST", body), "runReport");
62
+ const rows = parseReportRows(data);
63
+ return safeResponse({ rows, row_count: rows.length, date_range: `${startDate} to ${endDate}` }, "runReport");
64
+ }
65
+ async runRealtimeReport(propertyId, dimensions, metrics, dimensionFilter) {
66
+ const body = {
67
+ dimensions: dimensions.map((d) => ({ name: d })),
68
+ metrics: metrics.map((m) => ({ name: m })),
69
+ };
70
+ if (dimensionFilter) {
71
+ body.dimensionFilter = {
72
+ filter: {
73
+ fieldName: dimensionFilter.field,
74
+ stringFilter: { value: dimensionFilter.value },
75
+ },
76
+ };
77
+ }
78
+ const data = await withResilience(() => this.request(`${DATA_API}/properties/${propertyId}:runRealtimeReport`, "POST", body), "runRealtimeReport");
79
+ const rows = parseReportRows(data);
80
+ return safeResponse({ rows, row_count: rows.length }, "runRealtimeReport");
81
+ }
82
+ // ============================================
83
+ // ADMIN API
84
+ // ============================================
85
+ async listDataStreams(propertyId) {
86
+ const data = await withResilience(() => this.request(`${ADMIN_API}/properties/${propertyId}/dataStreams`), "listDataStreams");
87
+ const streams = (data.dataStreams || []).map((s) => ({
88
+ name: s.displayName,
89
+ type: s.type,
90
+ ...(s.webStreamData && {
91
+ measurement_id: s.webStreamData.measurementId,
92
+ default_uri: s.webStreamData.defaultUri,
93
+ }),
94
+ }));
95
+ return { data_streams: streams, count: streams.length };
96
+ }
97
+ async listCustomDimensions(propertyId) {
98
+ const data = await withResilience(() => this.request(`${ADMIN_API}/properties/${propertyId}/customDimensions`), "listCustomDimensions");
99
+ const dims = (data.customDimensions || []).map((d) => ({
100
+ name: d.displayName,
101
+ parameter_name: d.parameterName,
102
+ scope: d.scope,
103
+ description: d.description || "",
104
+ }));
105
+ return { custom_dimensions: dims, count: dims.length };
106
+ }
107
+ async createCustomDimension(propertyId, parameterName, displayName, scope, description) {
108
+ const body = {
109
+ parameterName,
110
+ displayName,
111
+ scope: scope.toUpperCase() === "USER" ? "USER_SCOPE" : "EVENT_SCOPE",
112
+ description,
113
+ };
114
+ const result = await withResilience(() => this.request(`${ADMIN_API}/properties/${propertyId}/customDimensions`, "POST", body), "createCustomDimension");
115
+ return {
116
+ created: result.displayName,
117
+ parameter_name: result.parameterName,
118
+ scope: result.scope,
119
+ };
120
+ }
121
+ async listCustomMetrics(propertyId) {
122
+ const data = await withResilience(() => this.request(`${ADMIN_API}/properties/${propertyId}/customMetrics`), "listCustomMetrics");
123
+ const metrics = (data.customMetrics || []).map((m) => ({
124
+ name: m.displayName,
125
+ parameter_name: m.parameterName,
126
+ scope: m.scope,
127
+ measurement_unit: m.measurementUnit,
128
+ description: m.description || "",
129
+ }));
130
+ return { custom_metrics: metrics, count: metrics.length };
131
+ }
132
+ }
133
+ // ============================================
134
+ // HELPERS
135
+ // ============================================
136
+ function parseReportRows(data) {
137
+ if (!data.rows)
138
+ return [];
139
+ const dimHeaders = (data.dimensionHeaders || []).map((h) => h.name);
140
+ const metHeaders = (data.metricHeaders || []).map((h) => h.name);
141
+ return data.rows.map((row) => {
142
+ const r = {};
143
+ (row.dimensionValues || []).forEach((v, i) => {
144
+ r[dimHeaders[i]] = v.value;
145
+ });
146
+ (row.metricValues || []).forEach((v, i) => {
147
+ r[metHeaders[i]] = v.value;
148
+ });
149
+ return r;
150
+ });
151
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-ga4 -- MCP server for querying Google Analytics 4 via natural language.
4
+ *
5
+ * Supports two config modes:
6
+ * 1. Single-property (env vars): GA4_PROPERTY_ID + GOOGLE_APPLICATION_CREDENTIALS
7
+ * 2. Multi-client (config.json): MCP_GA4_CONFIG or ~/.config/mcp-ga4/config.json
8
+ */
9
+ export {};