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 +106 -0
- package/dist/auth.d.ts +34 -0
- package/dist/auth.js +130 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +59 -0
- package/dist/ga4.d.ts +23 -0
- package/dist/ga4.js +151 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +280 -0
- package/dist/resilience.d.ts +6 -0
- package/dist/resilience.js +79 -0
- package/dist/setup.d.ts +13 -0
- package/dist/setup.js +273 -0
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +218 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
import { readFileSync, existsSync, appendFileSync, mkdirSync } from "fs";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
15
|
+
import { tools } from "./tools.js";
|
|
16
|
+
import { GA4Manager } from "./ga4.js";
|
|
17
|
+
import { validateCredentials } from "./auth.js";
|
|
18
|
+
import { classifyError, GA4AuthError, GA4RateLimitError, GA4ServiceError } from "./errors.js";
|
|
19
|
+
import { logger } from "./resilience.js";
|
|
20
|
+
function loadConfig() {
|
|
21
|
+
// Mode 1: Single-property via env vars
|
|
22
|
+
const propertyId = process.env.GA4_PROPERTY_ID;
|
|
23
|
+
const credsFile = process.env.GOOGLE_APPLICATION_CREDENTIALS || "";
|
|
24
|
+
if (propertyId) {
|
|
25
|
+
return {
|
|
26
|
+
credentials_file: credsFile,
|
|
27
|
+
clients: {
|
|
28
|
+
default: {
|
|
29
|
+
name: process.env.GA4_PROPERTY_NAME || "My Property",
|
|
30
|
+
folder: "",
|
|
31
|
+
property_id: propertyId,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Mode 2: Config file
|
|
37
|
+
const configPath = findConfigFile();
|
|
38
|
+
if (!configPath) {
|
|
39
|
+
throw new Error("No GA4 configuration found. Either:\n" +
|
|
40
|
+
" 1. Set GA4_PROPERTY_ID and GOOGLE_APPLICATION_CREDENTIALS env vars, or\n" +
|
|
41
|
+
" 2. Run 'npx mcp-ga4-setup' for guided setup, or\n" +
|
|
42
|
+
" 3. Create ~/.config/mcp-ga4/config.json");
|
|
43
|
+
}
|
|
44
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
45
|
+
const clients = {};
|
|
46
|
+
for (const [key, c] of Object.entries(raw.clients || {})) {
|
|
47
|
+
const client = c;
|
|
48
|
+
clients[key] = {
|
|
49
|
+
name: client.name,
|
|
50
|
+
folder: client.folder,
|
|
51
|
+
property_id: client.property_id,
|
|
52
|
+
sandbox_measurement_id: client.sandbox_measurement_id,
|
|
53
|
+
production_measurement_id: client.production_measurement_id,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
credentials_file: raw.credentials_file || credsFile,
|
|
58
|
+
clients,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function findConfigFile() {
|
|
62
|
+
// 1. Explicit env var
|
|
63
|
+
const envPath = process.env.MCP_GA4_CONFIG;
|
|
64
|
+
if (envPath && existsSync(envPath))
|
|
65
|
+
return envPath;
|
|
66
|
+
// 2. XDG-style user config
|
|
67
|
+
const xdg = join(homedir(), ".config", "mcp-ga4", "config.json");
|
|
68
|
+
if (existsSync(xdg))
|
|
69
|
+
return xdg;
|
|
70
|
+
// 3. Package-relative (dev mode)
|
|
71
|
+
const legacy = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
|
|
72
|
+
if (existsSync(legacy))
|
|
73
|
+
return legacy;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function getClientFromWorkingDir(config, cwd) {
|
|
77
|
+
// Single-property mode: always return the one client
|
|
78
|
+
const keys = Object.keys(config.clients);
|
|
79
|
+
if (keys.length === 1 && keys[0] === "default") {
|
|
80
|
+
return config.clients.default;
|
|
81
|
+
}
|
|
82
|
+
for (const [key, client] of Object.entries(config.clients)) {
|
|
83
|
+
if (cwd.startsWith(client.folder) || cwd.includes(key)) {
|
|
84
|
+
return client;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// ============================================
|
|
90
|
+
// FEEDBACK
|
|
91
|
+
// ============================================
|
|
92
|
+
const FEEDBACK_DIR = join(homedir(), ".config", "mcp-ga4", "feedback");
|
|
93
|
+
function writeFeedback(filename, entry) {
|
|
94
|
+
mkdirSync(FEEDBACK_DIR, { recursive: true });
|
|
95
|
+
appendFileSync(join(FEEDBACK_DIR, filename), JSON.stringify(entry) + "\n");
|
|
96
|
+
}
|
|
97
|
+
// ============================================
|
|
98
|
+
// PARSE HELPERS
|
|
99
|
+
// ============================================
|
|
100
|
+
function splitCsv(s, fallback) {
|
|
101
|
+
const val = (s || fallback).trim();
|
|
102
|
+
return val.split(",").map((x) => x.trim()).filter(Boolean);
|
|
103
|
+
}
|
|
104
|
+
function parseFilter(raw) {
|
|
105
|
+
if (!raw || !raw.includes("=="))
|
|
106
|
+
return undefined;
|
|
107
|
+
const [field, value] = raw.split("==", 2);
|
|
108
|
+
return { field: field.trim(), value: value.trim() };
|
|
109
|
+
}
|
|
110
|
+
// ============================================
|
|
111
|
+
// SERVER
|
|
112
|
+
// ============================================
|
|
113
|
+
const GA4_INSTRUCTIONS = `GA4 Analytics MCP - Query Google Analytics 4 data using natural language.
|
|
114
|
+
|
|
115
|
+
## Quick Start
|
|
116
|
+
1. Call ga4_get_client_context first to confirm which property you're connected to.
|
|
117
|
+
2. Use ga4_run_report for historical data or ga4_realtime_report for live data.
|
|
118
|
+
|
|
119
|
+
## Common Dimension & Metric API Names
|
|
120
|
+
Dimensions: date, dateHour, eventName, pagePath, pageTitle, sessionSource,
|
|
121
|
+
sessionMedium, sessionCampaignName, country, city, deviceCategory,
|
|
122
|
+
browser, operatingSystem, landingPage, pageReferrer, newVsReturning,
|
|
123
|
+
firstUserSource, firstUserMedium, firstUserCampaignName
|
|
124
|
+
Metrics: sessions, totalUsers, newUsers, activeUsers, screenPageViews,
|
|
125
|
+
eventCount, conversions, engagedSessions, engagementRate,
|
|
126
|
+
averageSessionDuration, bounceRate, sessionsPerUser,
|
|
127
|
+
screenPageViewsPerSession, userEngagementDuration
|
|
128
|
+
|
|
129
|
+
## Date Formats
|
|
130
|
+
Use YYYY-MM-DD or relative: "today", "yesterday", "7daysAgo", "30daysAgo", "90daysAgo"
|
|
131
|
+
|
|
132
|
+
## Common Query Patterns
|
|
133
|
+
- Top pages: dimensions="pagePath", metrics="screenPageViews", order_by="screenPageViews"
|
|
134
|
+
- Traffic sources: dimensions="sessionSource,sessionMedium", metrics="sessions,totalUsers"
|
|
135
|
+
- Daily trend: dimensions="date", metrics="sessions,totalUsers"
|
|
136
|
+
- Campaign performance: dimensions="sessionCampaignName", metrics="sessions,conversions"
|
|
137
|
+
- Device breakdown: dimensions="deviceCategory", metrics="sessions,totalUsers"
|
|
138
|
+
- Geographic: dimensions="country", metrics="sessions,totalUsers"
|
|
139
|
+
|
|
140
|
+
## Data Freshness
|
|
141
|
+
- Standard reports: 24-48 hour delay
|
|
142
|
+
- Realtime reports: last 30 minutes only
|
|
143
|
+
|
|
144
|
+
## Feedback
|
|
145
|
+
If a query doesn't return what the user expected, call ga4_suggest_improvement.`;
|
|
146
|
+
async function main() {
|
|
147
|
+
const config = loadConfig();
|
|
148
|
+
// Validate credentials at startup
|
|
149
|
+
const credCheck = validateCredentials(config.credentials_file);
|
|
150
|
+
if (!credCheck.valid) {
|
|
151
|
+
logger.error({ error: credCheck.error }, "Credential validation failed -- MCP will start but API calls will fail");
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
logger.info({ type: credCheck.type }, "Credentials validated");
|
|
155
|
+
}
|
|
156
|
+
const ga4 = new GA4Manager(config.credentials_file);
|
|
157
|
+
const server = new Server({ name: "mcp-ga4", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
158
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
159
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
160
|
+
const { name, arguments: args } = request.params;
|
|
161
|
+
try {
|
|
162
|
+
switch (name) {
|
|
163
|
+
// ---- Context ----
|
|
164
|
+
case "ga4_get_client_context": {
|
|
165
|
+
const cwd = args?.working_directory || "";
|
|
166
|
+
const client = getClientFromWorkingDir(config, cwd);
|
|
167
|
+
if (!client) {
|
|
168
|
+
return text({
|
|
169
|
+
error: "No client found for working directory",
|
|
170
|
+
working_directory: cwd,
|
|
171
|
+
available_clients: Object.entries(config.clients).map(([k, v]) => ({ key: k, name: v.name, folder: v.folder })),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return text({
|
|
175
|
+
client_name: client.name,
|
|
176
|
+
property_id: client.property_id,
|
|
177
|
+
folder: client.folder,
|
|
178
|
+
sandbox_measurement_id: client.sandbox_measurement_id,
|
|
179
|
+
production_measurement_id: client.production_measurement_id,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// ---- Reports ----
|
|
183
|
+
case "ga4_run_report": {
|
|
184
|
+
const result = await ga4.runReport(args?.property_id, splitCsv(args?.dimensions, "eventName"), splitCsv(args?.metrics, "eventCount"), args?.start_date || "7daysAgo", args?.end_date || "today", parseFilter(args?.dimension_filter), args?.limit || 100, args?.order_by || undefined);
|
|
185
|
+
return text(result);
|
|
186
|
+
}
|
|
187
|
+
case "ga4_realtime_report": {
|
|
188
|
+
const result = await ga4.runRealtimeReport(args?.property_id, splitCsv(args?.dimensions, "eventName"), splitCsv(args?.metrics, "eventCount"), parseFilter(args?.dimension_filter));
|
|
189
|
+
return text(result);
|
|
190
|
+
}
|
|
191
|
+
// ---- Admin ----
|
|
192
|
+
case "ga4_list_data_streams": {
|
|
193
|
+
const result = await ga4.listDataStreams(args?.property_id);
|
|
194
|
+
return text(result);
|
|
195
|
+
}
|
|
196
|
+
case "ga4_list_custom_dimensions": {
|
|
197
|
+
const result = await ga4.listCustomDimensions(args?.property_id);
|
|
198
|
+
return text(result);
|
|
199
|
+
}
|
|
200
|
+
case "ga4_create_custom_dimension": {
|
|
201
|
+
const result = await ga4.createCustomDimension(args?.property_id, args?.parameter_name, args?.display_name, args?.scope || "EVENT", args?.description || "");
|
|
202
|
+
return text(result);
|
|
203
|
+
}
|
|
204
|
+
case "ga4_list_custom_metrics": {
|
|
205
|
+
const result = await ga4.listCustomMetrics(args?.property_id);
|
|
206
|
+
return text(result);
|
|
207
|
+
}
|
|
208
|
+
// ---- Feedback ----
|
|
209
|
+
case "ga4_send_feedback": {
|
|
210
|
+
const feedbackType = args?.feedback_type;
|
|
211
|
+
if (!["bug", "feature", "question"].includes(feedbackType)) {
|
|
212
|
+
return text({
|
|
213
|
+
error: 'feedback_type must be: bug, feature, or question',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
writeFeedback("feedback.jsonl", {
|
|
217
|
+
type: feedbackType,
|
|
218
|
+
message: args?.message,
|
|
219
|
+
query_context: args?.query_context || "",
|
|
220
|
+
timestamp: new Date().toISOString(),
|
|
221
|
+
});
|
|
222
|
+
return text({
|
|
223
|
+
status: "recorded",
|
|
224
|
+
feedback_type: feedbackType,
|
|
225
|
+
message: "Feedback saved. For urgent issues, file at: https://github.com/drak-marketing/mcp-ga4/issues",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
case "ga4_suggest_improvement": {
|
|
229
|
+
writeFeedback("improvements.jsonl", {
|
|
230
|
+
type: "improvement",
|
|
231
|
+
failed_query: args?.failed_query,
|
|
232
|
+
expected_result: args?.expected_result,
|
|
233
|
+
actual_result: args?.actual_result || "",
|
|
234
|
+
timestamp: new Date().toISOString(),
|
|
235
|
+
});
|
|
236
|
+
return text({
|
|
237
|
+
status: "recorded",
|
|
238
|
+
message: "Query pattern logged for improvement. This helps make GA4 queries more accurate for everyone.",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
default:
|
|
242
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (rawError) {
|
|
246
|
+
const error = classifyError(rawError);
|
|
247
|
+
logger.error({ errorType: error.name, tool: name }, error.message);
|
|
248
|
+
const response = {
|
|
249
|
+
error: true,
|
|
250
|
+
error_type: error.name,
|
|
251
|
+
message: error.message,
|
|
252
|
+
};
|
|
253
|
+
if (error instanceof GA4AuthError) {
|
|
254
|
+
response.action_required =
|
|
255
|
+
"Re-authenticate: run 'npx mcp-ga4-setup' to refresh credentials.";
|
|
256
|
+
}
|
|
257
|
+
else if (error instanceof GA4RateLimitError) {
|
|
258
|
+
response.retry_after_ms = error.retryAfterMs;
|
|
259
|
+
response.action_required = `Rate limited. Retry after ${Math.ceil(error.retryAfterMs / 1000)} seconds.`;
|
|
260
|
+
}
|
|
261
|
+
else if (error instanceof GA4ServiceError) {
|
|
262
|
+
response.action_required =
|
|
263
|
+
"GA4 API server error. This is transient -- retry in a few minutes.";
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
267
|
+
isError: true,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
const transport = new StdioServerTransport();
|
|
272
|
+
await server.connect(transport);
|
|
273
|
+
logger.info("mcp-ga4 server running");
|
|
274
|
+
}
|
|
275
|
+
function text(data) {
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
main().catch((err) => logger.error({ error: err.message, stack: err.stack }, "Fatal startup error"));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger, retry/circuit-breaker/timeout, and response size limiting.
|
|
3
|
+
*/
|
|
4
|
+
export declare const logger: import("pino").Logger<never>;
|
|
5
|
+
export declare function safeResponse<T>(data: T, context: string): T;
|
|
6
|
+
export declare function withResilience<T>(fn: () => Promise<T>, operationName: string): Promise<T>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger, retry/circuit-breaker/timeout, and response size limiting.
|
|
3
|
+
*/
|
|
4
|
+
import { retry, circuitBreaker, wrap, handleAll, timeout, TimeoutStrategy, ExponentialBackoff, ConsecutiveBreaker, } from "cockatiel";
|
|
5
|
+
import pino from "pino";
|
|
6
|
+
// ============================================
|
|
7
|
+
// LOGGER
|
|
8
|
+
// ============================================
|
|
9
|
+
export const logger = pino({
|
|
10
|
+
level: process.env.LOG_LEVEL || "info",
|
|
11
|
+
...(process.env.NODE_ENV !== "test" && {
|
|
12
|
+
transport: {
|
|
13
|
+
target: "pino-pretty",
|
|
14
|
+
options: {
|
|
15
|
+
colorize: true,
|
|
16
|
+
singleLine: true,
|
|
17
|
+
translateTime: "SYS:standard",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
// ============================================
|
|
23
|
+
// SAFE RESPONSE (Response Size Limiting)
|
|
24
|
+
// ============================================
|
|
25
|
+
const MAX_RESPONSE_SIZE = 200_000; // 200KB
|
|
26
|
+
export function safeResponse(data, context) {
|
|
27
|
+
const jsonStr = JSON.stringify(data);
|
|
28
|
+
const sizeBytes = Buffer.byteLength(jsonStr, "utf-8");
|
|
29
|
+
if (sizeBytes > MAX_RESPONSE_SIZE) {
|
|
30
|
+
logger.warn({ sizeBytes, maxSize: MAX_RESPONSE_SIZE, context }, "Response exceeds size limit, truncating");
|
|
31
|
+
if (Array.isArray(data)) {
|
|
32
|
+
return data.slice(0, Math.max(1, Math.floor(data.length * 0.5)));
|
|
33
|
+
}
|
|
34
|
+
if (typeof data === "object" && data !== null) {
|
|
35
|
+
const obj = data;
|
|
36
|
+
for (const key of ["items", "results", "data", "rows"]) {
|
|
37
|
+
if (Array.isArray(obj[key])) {
|
|
38
|
+
obj[key] = obj[key].slice(0, Math.max(1, Math.floor(obj[key].length * 0.5)));
|
|
39
|
+
return obj;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
// ============================================
|
|
47
|
+
// RETRY + CIRCUIT BREAKER + TIMEOUT
|
|
48
|
+
// ============================================
|
|
49
|
+
const backoff = new ExponentialBackoff({
|
|
50
|
+
initialDelay: 100,
|
|
51
|
+
maxDelay: 5_000,
|
|
52
|
+
});
|
|
53
|
+
const retryPolicy = retry(handleAll, { maxAttempts: 3, backoff });
|
|
54
|
+
const circuitBreakerPolicy = circuitBreaker(handleAll, {
|
|
55
|
+
halfOpenAfter: 60_000,
|
|
56
|
+
breaker: new ConsecutiveBreaker(5),
|
|
57
|
+
});
|
|
58
|
+
const timeoutPolicy = timeout(30_000, TimeoutStrategy.Cooperative);
|
|
59
|
+
const policy = wrap(timeoutPolicy, circuitBreakerPolicy, retryPolicy);
|
|
60
|
+
export async function withResilience(fn, operationName) {
|
|
61
|
+
try {
|
|
62
|
+
logger.debug({ operation: operationName }, "Starting API call");
|
|
63
|
+
const result = await policy.execute(() => fn());
|
|
64
|
+
logger.debug({ operation: operationName }, "API call succeeded");
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
let error;
|
|
69
|
+
if (err instanceof Error) {
|
|
70
|
+
error = err;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
error = new Error(String(err));
|
|
74
|
+
error.cause = err;
|
|
75
|
+
}
|
|
76
|
+
logger.error({ operation: operationName, error: error.message }, "API call failed after retries");
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Interactive setup wizard for mcp-ga4.
|
|
4
|
+
*
|
|
5
|
+
* Guides users through:
|
|
6
|
+
* 1. Entering their GA4 property ID
|
|
7
|
+
* 2. Google OAuth authentication
|
|
8
|
+
* 3. Connection verification
|
|
9
|
+
* 4. Claude Code MCP configuration
|
|
10
|
+
*
|
|
11
|
+
* OAuth client credentials loaded from env vars or macOS Keychain.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Interactive setup wizard for mcp-ga4.
|
|
4
|
+
*
|
|
5
|
+
* Guides users through:
|
|
6
|
+
* 1. Entering their GA4 property ID
|
|
7
|
+
* 2. Google OAuth authentication
|
|
8
|
+
* 3. Connection verification
|
|
9
|
+
* 4. Claude Code MCP configuration
|
|
10
|
+
*
|
|
11
|
+
* OAuth client credentials loaded from env vars or macOS Keychain.
|
|
12
|
+
*/
|
|
13
|
+
import { createInterface } from "readline";
|
|
14
|
+
import { createServer } from "http";
|
|
15
|
+
import { mkdirSync, writeFileSync, readFileSync, chmodSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { homedir, platform } from "os";
|
|
18
|
+
import { execSync } from "child_process";
|
|
19
|
+
import { URL } from "url";
|
|
20
|
+
const CONFIG_DIR = join(homedir(), ".config", "mcp-ga4");
|
|
21
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
22
|
+
const SCOPES = ["https://www.googleapis.com/auth/analytics.readonly"];
|
|
23
|
+
const REDIRECT_PORT = 8087;
|
|
24
|
+
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`;
|
|
25
|
+
// ============================================
|
|
26
|
+
// HELPERS
|
|
27
|
+
// ============================================
|
|
28
|
+
function printHeader(text) {
|
|
29
|
+
console.log(`\n${"=".repeat(50)}`);
|
|
30
|
+
console.log(` ${text}`);
|
|
31
|
+
console.log(`${"=".repeat(50)}\n`);
|
|
32
|
+
}
|
|
33
|
+
function ask(question) {
|
|
34
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
rl.question(question, (answer) => {
|
|
37
|
+
rl.close();
|
|
38
|
+
resolve(answer.trim());
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function getOAuthCredentials() {
|
|
43
|
+
// Try env vars first
|
|
44
|
+
let clientId = process.env.OAUTH_CLIENT_ID || "";
|
|
45
|
+
let clientSecret = process.env.OAUTH_CLIENT_SECRET || "";
|
|
46
|
+
if (clientId && clientSecret)
|
|
47
|
+
return { clientId, clientSecret };
|
|
48
|
+
// Try macOS Keychain
|
|
49
|
+
if (platform() === "darwin") {
|
|
50
|
+
try {
|
|
51
|
+
if (!clientId) {
|
|
52
|
+
clientId = execSync('security find-generic-password -a mcp-ga4 -s OAUTH_CLIENT_ID -w', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
53
|
+
}
|
|
54
|
+
if (!clientSecret) {
|
|
55
|
+
clientSecret = execSync('security find-generic-password -a mcp-ga4 -s OAUTH_CLIENT_SECRET -w', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Not found in Keychain
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!clientId || !clientSecret) {
|
|
63
|
+
console.error("OAuth client credentials not found.");
|
|
64
|
+
console.error("Set OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET environment variables,");
|
|
65
|
+
console.error("or on macOS, store them in Keychain:");
|
|
66
|
+
console.error(' security add-generic-password -a mcp-ga4 -s OAUTH_CLIENT_ID -w \'<your-client-id>\'');
|
|
67
|
+
console.error(' security add-generic-password -a mcp-ga4 -s OAUTH_CLIENT_SECRET -w \'<your-secret>\'');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
return { clientId, clientSecret };
|
|
71
|
+
}
|
|
72
|
+
// ============================================
|
|
73
|
+
// STEPS
|
|
74
|
+
// ============================================
|
|
75
|
+
async function collectPropertyId() {
|
|
76
|
+
printHeader("Step 1: GA4 Property ID");
|
|
77
|
+
console.log("Your GA4 property ID is a 9-digit number.");
|
|
78
|
+
console.log("Find it at: GA4 > Admin (gear icon) > Property Details");
|
|
79
|
+
console.log("It's listed as 'Property ID' near the top.\n");
|
|
80
|
+
while (true) {
|
|
81
|
+
const id = await ask("Enter your GA4 property ID: ");
|
|
82
|
+
if (/^\d{6,12}$/.test(id))
|
|
83
|
+
return id;
|
|
84
|
+
console.log(" That doesn't look right. Enter just the numeric ID (e.g., 331956119).\n");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function runOAuth(clientId, clientSecret) {
|
|
88
|
+
printHeader("Step 2: Google Authentication");
|
|
89
|
+
console.log("A browser window will open for Google sign-in.");
|
|
90
|
+
console.log("Sign in with the Google account that has access to your GA4 property.");
|
|
91
|
+
console.log('You may see a warning about an unverified app -- click "Advanced"');
|
|
92
|
+
console.log('then "Go to mcp-ga4 (unsafe)" to continue. This is safe.\n');
|
|
93
|
+
const authCode = await new Promise((resolve, reject) => {
|
|
94
|
+
const server = createServer((req, res) => {
|
|
95
|
+
const url = new URL(req.url || "", REDIRECT_URI);
|
|
96
|
+
const code = url.searchParams.get("code");
|
|
97
|
+
const error = url.searchParams.get("error");
|
|
98
|
+
if (code) {
|
|
99
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
100
|
+
res.end('<html><body style="font-family:sans-serif;text-align:center;padding:60px">' +
|
|
101
|
+
"<h2>Authorization successful!</h2>" +
|
|
102
|
+
"<p>You can close this tab and return to your terminal.</p>" +
|
|
103
|
+
"</body></html>");
|
|
104
|
+
server.close();
|
|
105
|
+
resolve(code);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
109
|
+
res.end(`<h2>Error: ${error || "unknown"}</h2>`);
|
|
110
|
+
server.close();
|
|
111
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
server.listen(REDIRECT_PORT, async () => {
|
|
115
|
+
const scope = encodeURIComponent(SCOPES.join(" "));
|
|
116
|
+
const authUrl = `https://accounts.google.com/o/oauth2/auth` +
|
|
117
|
+
`?client_id=${clientId}` +
|
|
118
|
+
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
|
|
119
|
+
`&response_type=code` +
|
|
120
|
+
`&scope=${scope}` +
|
|
121
|
+
`&access_type=offline` +
|
|
122
|
+
`&prompt=consent`;
|
|
123
|
+
await ask("Press Enter to open your browser for Google sign-in...");
|
|
124
|
+
// Dynamic import for ESM 'open' package
|
|
125
|
+
const { default: openBrowser } = await import("open");
|
|
126
|
+
await openBrowser(authUrl);
|
|
127
|
+
console.log("Waiting for authorization...");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
// Exchange code for tokens
|
|
131
|
+
const params = new URLSearchParams({
|
|
132
|
+
code: authCode,
|
|
133
|
+
client_id: clientId,
|
|
134
|
+
client_secret: clientSecret,
|
|
135
|
+
redirect_uri: REDIRECT_URI,
|
|
136
|
+
grant_type: "authorization_code",
|
|
137
|
+
});
|
|
138
|
+
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
141
|
+
body: params.toString(),
|
|
142
|
+
});
|
|
143
|
+
if (!resp.ok) {
|
|
144
|
+
const text = await resp.text();
|
|
145
|
+
console.error(`\nToken exchange failed: ${text}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
const tokens = (await resp.json());
|
|
149
|
+
if (!tokens.refresh_token) {
|
|
150
|
+
console.error("\nNo refresh token received. Try revoking app access at");
|
|
151
|
+
console.error("https://myaccount.google.com/permissions and re-running setup.");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
return tokens;
|
|
155
|
+
}
|
|
156
|
+
function saveCredentials(tokens, clientId, clientSecret) {
|
|
157
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
158
|
+
const creds = {
|
|
159
|
+
type: "authorized_user",
|
|
160
|
+
client_id: clientId,
|
|
161
|
+
client_secret: clientSecret,
|
|
162
|
+
refresh_token: tokens.refresh_token,
|
|
163
|
+
};
|
|
164
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2));
|
|
165
|
+
chmodSync(CREDENTIALS_FILE, 0o600);
|
|
166
|
+
console.log(`\nCredentials saved to: ${CREDENTIALS_FILE}`);
|
|
167
|
+
return CREDENTIALS_FILE;
|
|
168
|
+
}
|
|
169
|
+
async function verifyConnection(propertyId, credsFile) {
|
|
170
|
+
printHeader("Step 3: Verifying Connection");
|
|
171
|
+
console.log("Testing connection to your GA4 property...");
|
|
172
|
+
try {
|
|
173
|
+
// Read creds and get an access token
|
|
174
|
+
const creds = JSON.parse(readFileSync(credsFile, "utf-8"));
|
|
175
|
+
const params = new URLSearchParams({
|
|
176
|
+
grant_type: "refresh_token",
|
|
177
|
+
client_id: creds.client_id,
|
|
178
|
+
client_secret: creds.client_secret,
|
|
179
|
+
refresh_token: creds.refresh_token,
|
|
180
|
+
});
|
|
181
|
+
const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
184
|
+
body: params.toString(),
|
|
185
|
+
});
|
|
186
|
+
if (!tokenResp.ok) {
|
|
187
|
+
throw new Error(`Token refresh failed: ${await tokenResp.text()}`);
|
|
188
|
+
}
|
|
189
|
+
const { access_token } = (await tokenResp.json());
|
|
190
|
+
// List data streams to verify access
|
|
191
|
+
const apiResp = await fetch(`https://analyticsadmin.googleapis.com/v1beta/properties/${propertyId}/dataStreams`, { headers: { Authorization: `Bearer ${access_token}` } });
|
|
192
|
+
if (!apiResp.ok) {
|
|
193
|
+
throw new Error(`API call failed: ${await apiResp.text()}`);
|
|
194
|
+
}
|
|
195
|
+
const data = (await apiResp.json());
|
|
196
|
+
const streams = data.dataStreams || [];
|
|
197
|
+
console.log("\nConnected successfully!");
|
|
198
|
+
console.log(` Property ID: ${propertyId}`);
|
|
199
|
+
console.log(` Data streams found: ${streams.length}`);
|
|
200
|
+
for (const s of streams) {
|
|
201
|
+
const mid = s.webStreamData?.measurementId;
|
|
202
|
+
console.log(` - ${s.displayName}${mid ? ` (${mid})` : ""}`);
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
console.error(`\nConnection failed: ${err.message}`);
|
|
208
|
+
console.error("\nPossible causes:");
|
|
209
|
+
console.error(" - Wrong property ID");
|
|
210
|
+
console.error(" - Your Google account doesn't have access to this property");
|
|
211
|
+
console.error(" - Try re-running: npx mcp-ga4-setup");
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function generateConfig(propertyId, credsFile) {
|
|
216
|
+
printHeader("Step 4: Claude Code Configuration");
|
|
217
|
+
const mcpConfig = {
|
|
218
|
+
ga4: {
|
|
219
|
+
command: "npx",
|
|
220
|
+
args: ["-y", "mcp-ga4"],
|
|
221
|
+
env: {
|
|
222
|
+
GA4_PROPERTY_ID: propertyId,
|
|
223
|
+
GOOGLE_APPLICATION_CREDENTIALS: credsFile,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
console.log("Add this to your Claude Code MCP configuration:\n");
|
|
228
|
+
if (platform() === "darwin") {
|
|
229
|
+
const configPath = join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
230
|
+
console.log(` File: ${configPath}`);
|
|
231
|
+
console.log(" (Or for Claude Code CLI: add to .mcp.json in your project)\n");
|
|
232
|
+
}
|
|
233
|
+
else if (platform() === "win32") {
|
|
234
|
+
const configPath = join(homedir(), "AppData", "Roaming", "Claude", "claude_desktop_config.json");
|
|
235
|
+
console.log(` File: ${configPath}\n`);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
console.log(" Add to .mcp.json in your project directory:\n");
|
|
239
|
+
}
|
|
240
|
+
console.log('Add this inside the "mcpServers" key:\n');
|
|
241
|
+
console.log(JSON.stringify(mcpConfig, null, 2));
|
|
242
|
+
// Offer to write .mcp.json
|
|
243
|
+
console.log(`\n${"=".repeat(50)}`);
|
|
244
|
+
}
|
|
245
|
+
// ============================================
|
|
246
|
+
// MAIN
|
|
247
|
+
// ============================================
|
|
248
|
+
async function main() {
|
|
249
|
+
printHeader("mcp-ga4 Setup");
|
|
250
|
+
console.log("This wizard will connect Claude to your Google Analytics 4 property.");
|
|
251
|
+
console.log("You'll need:");
|
|
252
|
+
console.log(" 1. Your GA4 property ID (a 9-digit number)");
|
|
253
|
+
console.log(" 2. A Google account with access to that GA4 property\n");
|
|
254
|
+
const { clientId, clientSecret } = getOAuthCredentials();
|
|
255
|
+
const propertyId = await collectPropertyId();
|
|
256
|
+
const tokens = await runOAuth(clientId, clientSecret);
|
|
257
|
+
const credsFile = saveCredentials(tokens, clientId, clientSecret);
|
|
258
|
+
if (await verifyConnection(propertyId, credsFile)) {
|
|
259
|
+
generateConfig(propertyId, credsFile);
|
|
260
|
+
printHeader("Setup Complete!");
|
|
261
|
+
console.log("You can now use Claude to query your GA4 data.");
|
|
262
|
+
console.log('Try asking: "What were my top 10 pages last week?"\n');
|
|
263
|
+
console.log("To update or re-authenticate: npx mcp-ga4-setup");
|
|
264
|
+
console.log("To report issues: use the ga4_send_feedback tool in Claude\n");
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
main().catch((err) => {
|
|
271
|
+
console.error(`Setup failed: ${err.message}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
});
|