@dongsik/ga4-mcp 0.1.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,123 @@
1
+ # ga4-mcp
2
+
3
+ [한국어](docs/README.ko.md)
4
+
5
+ An MCP server for querying and analyzing Google Analytics 4 data directly from Claude Desktop.
6
+
7
+ ## Features
8
+
9
+ ### Basic Queries
10
+ | Tool | Description |
11
+ |---|---|
12
+ | `list_accounts` | List GA4 accounts |
13
+ | `list_properties` | List properties within an account |
14
+ | `run_report` | Run custom reports with flexible metric/dimension combinations and dimension filters |
15
+
16
+ ### Page & Traffic Analysis
17
+ | Tool | Description |
18
+ |---|---|
19
+ | `get_top_pages` | Top pages by pageviews, sessions, avg. duration, bounce rate |
20
+ | `get_traffic_sources` | Traffic source breakdown by source/medium |
21
+
22
+ ### User Analysis
23
+ | Tool | Description |
24
+ |---|---|
25
+ | `get_user_overview` | User overview (total users, new users, sessions, pageviews, etc.) |
26
+ | `get_users_by_country` | Users by country |
27
+ | `get_users_by_device` | Users by device category (desktop, mobile, tablet) |
28
+
29
+ ### Trends & Realtime
30
+ | Tool | Description |
31
+ |---|---|
32
+ | `get_trend_by_date` | Daily trends (pageviews, sessions, users over time) |
33
+ | `get_realtime` | Realtime active users and pageviews |
34
+
35
+ ### Campaign Analysis
36
+ | Tool | Description |
37
+ |---|---|
38
+ | `get_campaign_performance` | Campaign performance (sessions, users, conversions, bounce rate). Filter by campaign name |
39
+ | `get_utm_breakdown` | Full UTM parameter analysis (campaign, source, medium, content, keyword) |
40
+ | `compare_campaigns` | Compare performance across multiple campaigns |
41
+
42
+ ### Metadata
43
+ | Tool | Description |
44
+ |---|---|
45
+ | `get_metadata` | List all available metrics and dimensions |
46
+ | `search_metadata` | Search metrics/dimensions by keyword |
47
+ | `list_categories` | List metric/dimension categories |
48
+
49
+ ## Prerequisites
50
+
51
+ ### Google Cloud Console Setup
52
+
53
+ 1. Create or select a project in [Google Cloud Console](https://console.cloud.google.com/)
54
+ 2. **APIs & Services → Library** → Enable "Google Analytics Data API"
55
+ 3. **APIs & Services → Library** → Enable "Google Analytics Admin API"
56
+ 4. **APIs & Services → Credentials** → Create **OAuth 2.0 Client ID** (type: Desktop app)
57
+ 5. Download the JSON file and save as `client_secret.json`
58
+
59
+ ## Installation
60
+
61
+ ### Claude Desktop Configuration
62
+
63
+ Add to your `claude_desktop_config.json`:
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "ga": {
69
+ "command": "npx",
70
+ "args": ["-y", "ga4-mcp"],
71
+ "env": {
72
+ "GA_CLIENT_SECRET_PATH": "/path/to/client_secret.json",
73
+ "GA4_PROPERTY_ID": "123456789"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ > Windows path example: `"C:\\Users\\username\\client_secret.json"`
81
+
82
+ ### Environment Variables
83
+
84
+ | Variable | Required | Description |
85
+ |---|---|---|
86
+ | `GA_CLIENT_SECRET_PATH` | Yes | Path to OAuth client_secret.json file |
87
+ | `GA4_PROPERTY_ID` | No | Default GA4 property ID. When set, you don't need to specify propertyId for each request |
88
+
89
+ ### First Run
90
+
91
+ On first launch, a browser window will open for Google OAuth login. After authentication, the token is saved locally and subsequent requests will authenticate automatically.
92
+
93
+ ## Usage Examples
94
+
95
+ Ask questions in natural language through Claude Desktop:
96
+
97
+ - "Show me the top pages for the last 30 days"
98
+ - "Analyze traffic sources for this month"
99
+ - "Compare users this week vs last week"
100
+ - "Show user distribution by country"
101
+ - "How many users are online right now?"
102
+ - "Show performance for the spring_sale campaign"
103
+ - "Break down traffic by UTM parameters"
104
+ - "Compare campaign A vs campaign B"
105
+ - "What campaign-related dimensions are available in GA4?"
106
+
107
+ ## Dimension Filters
108
+
109
+ Use dimension filters in `run_report` to narrow down results:
110
+
111
+ ```
112
+ "Show only pages starting with /blog for the last 30 days"
113
+ → dimensionFilter: { fieldName: "pagePath", matchType: "BEGINS_WITH", value: "/blog" }
114
+
115
+ "Analyze only traffic from google"
116
+ → dimensionFilter: { fieldName: "sessionSource", matchType: "EXACT", value: "google" }
117
+ ```
118
+
119
+ Supported match types: `EXACT`, `BEGINS_WITH`, `ENDS_WITH`, `CONTAINS`, `REGEXP`
120
+
121
+ ## License
122
+
123
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { OAuth2Client } from "google-auth-library";
2
+ export declare function getAuthenticatedClient(clientSecretPath: string): Promise<OAuth2Client>;
package/dist/auth.js ADDED
@@ -0,0 +1,104 @@
1
+ import { google } from "googleapis";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import * as http from "http";
5
+ import open from "open";
6
+ const SCOPES = [
7
+ "https://www.googleapis.com/auth/analytics.readonly",
8
+ ];
9
+ const TOKEN_DIR = path.join(process.env.HOME || process.env.USERPROFILE || ".", ".ga-mcp");
10
+ const TOKEN_PATH = path.join(TOKEN_DIR, "token.json");
11
+ function loadClientCredentials(clientSecretPath) {
12
+ const content = fs.readFileSync(clientSecretPath, "utf-8");
13
+ const json = JSON.parse(content);
14
+ // Google Cloud Console exports as { installed: {...} } or { web: {...} }
15
+ const creds = json.installed || json.web;
16
+ if (!creds) {
17
+ throw new Error("client_secret.json must contain 'installed' or 'web' credentials");
18
+ }
19
+ return creds;
20
+ }
21
+ function loadSavedToken() {
22
+ try {
23
+ const content = fs.readFileSync(TOKEN_PATH, "utf-8");
24
+ return JSON.parse(content);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function saveToken(token) {
31
+ if (!fs.existsSync(TOKEN_DIR)) {
32
+ fs.mkdirSync(TOKEN_DIR, { recursive: true });
33
+ }
34
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(token, null, 2));
35
+ }
36
+ async function authorizeViaLocalServer(oauth2Client) {
37
+ const authUrl = oauth2Client.generateAuthUrl({
38
+ access_type: "offline",
39
+ scope: SCOPES,
40
+ prompt: "consent",
41
+ });
42
+ return new Promise((resolve, reject) => {
43
+ const server = http.createServer(async (req, res) => {
44
+ try {
45
+ const url = new URL(req.url, `http://localhost:3000`);
46
+ const code = url.searchParams.get("code");
47
+ if (!code) {
48
+ res.writeHead(400);
49
+ res.end("No authorization code received");
50
+ return;
51
+ }
52
+ const { tokens } = await oauth2Client.getToken(code);
53
+ oauth2Client.setCredentials(tokens);
54
+ saveToken(tokens);
55
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
56
+ res.end("<h1>인증 완료!</h1><p>이 창을 닫아도 됩니다.</p><script>window.close()</script>");
57
+ server.close();
58
+ resolve();
59
+ }
60
+ catch (err) {
61
+ res.writeHead(500);
62
+ res.end("Authentication failed");
63
+ server.close();
64
+ reject(err);
65
+ }
66
+ });
67
+ server.listen(3000, () => {
68
+ open(authUrl).catch(() => {
69
+ console.error(`Open this URL in your browser:\n${authUrl}`);
70
+ });
71
+ });
72
+ // Timeout after 2 minutes
73
+ setTimeout(() => {
74
+ server.close();
75
+ reject(new Error("Authentication timed out"));
76
+ }, 120000);
77
+ });
78
+ }
79
+ export async function getAuthenticatedClient(clientSecretPath) {
80
+ const creds = loadClientCredentials(clientSecretPath);
81
+ const oauth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, "http://localhost:3000");
82
+ // Try to use saved token
83
+ const savedToken = loadSavedToken();
84
+ if (savedToken) {
85
+ oauth2Client.setCredentials(savedToken);
86
+ // Refresh if expired
87
+ if (savedToken.expiry_date &&
88
+ savedToken.expiry_date < Date.now()) {
89
+ try {
90
+ const { credentials } = await oauth2Client.refreshAccessToken();
91
+ oauth2Client.setCredentials(credentials);
92
+ saveToken(credentials);
93
+ }
94
+ catch {
95
+ // Token refresh failed, need to re-auth
96
+ await authorizeViaLocalServer(oauth2Client);
97
+ }
98
+ }
99
+ return oauth2Client;
100
+ }
101
+ // No saved token, start OAuth flow
102
+ await authorizeViaLocalServer(oauth2Client);
103
+ return oauth2Client;
104
+ }
package/dist/ga.d.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { OAuth2Client } from "google-auth-library";
2
+ export interface DimensionFilter {
3
+ fieldName: string;
4
+ matchType: "EXACT" | "BEGINS_WITH" | "ENDS_WITH" | "CONTAINS" | "REGEXP";
5
+ value: string;
6
+ caseSensitive?: boolean;
7
+ }
8
+ export declare class GA4Client {
9
+ private analyticsData;
10
+ private analyticsAdmin;
11
+ constructor(auth: OAuth2Client);
12
+ listAccounts(): Promise<{
13
+ name: string | null | undefined;
14
+ displayName: string | null | undefined;
15
+ }[]>;
16
+ listProperties(accountId: string): Promise<{
17
+ name: string | null | undefined;
18
+ displayName: string | null | undefined;
19
+ propertyId: string | undefined;
20
+ }[]>;
21
+ runReport(params: {
22
+ propertyId: string;
23
+ startDate: string;
24
+ endDate: string;
25
+ metrics: string[];
26
+ dimensions?: string[];
27
+ dimensionFilters?: DimensionFilter[];
28
+ limit?: number;
29
+ orderBy?: string;
30
+ orderDesc?: boolean;
31
+ }): Promise<{
32
+ headers: string[];
33
+ rows: Record<string, string>[];
34
+ rowCount: number;
35
+ totals: any;
36
+ }>;
37
+ getTopPages(propertyId: string, startDate: string, endDate: string, limit?: number): Promise<{
38
+ headers: string[];
39
+ rows: Record<string, string>[];
40
+ rowCount: number;
41
+ totals: any;
42
+ }>;
43
+ getTrafficSources(propertyId: string, startDate: string, endDate: string, limit?: number): Promise<{
44
+ headers: string[];
45
+ rows: Record<string, string>[];
46
+ rowCount: number;
47
+ totals: any;
48
+ }>;
49
+ getUserOverview(propertyId: string, startDate: string, endDate: string): Promise<{
50
+ headers: string[];
51
+ rows: Record<string, string>[];
52
+ rowCount: number;
53
+ totals: any;
54
+ }>;
55
+ getUsersByCountry(propertyId: string, startDate: string, endDate: string, limit?: number): Promise<{
56
+ headers: string[];
57
+ rows: Record<string, string>[];
58
+ rowCount: number;
59
+ totals: any;
60
+ }>;
61
+ getUsersByDevice(propertyId: string, startDate: string, endDate: string): Promise<{
62
+ headers: string[];
63
+ rows: Record<string, string>[];
64
+ rowCount: number;
65
+ totals: any;
66
+ }>;
67
+ getPagesByDate(propertyId: string, startDate: string, endDate: string): Promise<{
68
+ headers: string[];
69
+ rows: Record<string, string>[];
70
+ rowCount: number;
71
+ totals: any;
72
+ }>;
73
+ getRealtimeReport(propertyId: string): Promise<{
74
+ headers: string[];
75
+ rows: Record<string, string>[];
76
+ rowCount: number;
77
+ totals: any;
78
+ }>;
79
+ getCampaignPerformance(propertyId: string, startDate: string, endDate: string, limit?: number, campaignName?: string): Promise<{
80
+ headers: string[];
81
+ rows: Record<string, string>[];
82
+ rowCount: number;
83
+ totals: any;
84
+ }>;
85
+ getUtmBreakdown(propertyId: string, startDate: string, endDate: string, limit?: number): Promise<{
86
+ headers: string[];
87
+ rows: Record<string, string>[];
88
+ rowCount: number;
89
+ totals: any;
90
+ }>;
91
+ getCampaignComparison(propertyId: string, startDate: string, endDate: string, campaignNames: string[]): Promise<Record<string, any>>;
92
+ getMetadata(propertyId: string): Promise<{
93
+ dimensions: {
94
+ apiName: string | null | undefined;
95
+ uiName: string | null | undefined;
96
+ category: string | null | undefined;
97
+ description: string | null | undefined;
98
+ }[];
99
+ metrics: {
100
+ apiName: string | null | undefined;
101
+ uiName: string | null | undefined;
102
+ category: string | null | undefined;
103
+ description: string | null | undefined;
104
+ type: string | null | undefined;
105
+ }[];
106
+ }>;
107
+ searchMetadata(propertyId: string, keyword: string, type?: "dimensions" | "metrics"): Promise<any>;
108
+ listCategories(propertyId: string): Promise<{
109
+ dimensionCategories: (string | null | undefined)[];
110
+ metricCategories: (string | null | undefined)[];
111
+ }>;
112
+ private buildDimensionFilter;
113
+ private formatReportResponse;
114
+ }
package/dist/ga.js ADDED
@@ -0,0 +1,310 @@
1
+ import { google } from "googleapis";
2
+ export class GA4Client {
3
+ analyticsData;
4
+ analyticsAdmin;
5
+ constructor(auth) {
6
+ this.analyticsData = google.analyticsdata({ version: "v1beta", auth });
7
+ this.analyticsAdmin = google.analyticsadmin({ version: "v1beta", auth });
8
+ }
9
+ async listAccounts() {
10
+ const res = await this.analyticsAdmin.accounts.list();
11
+ return (res.data.accounts || []).map((a) => ({
12
+ name: a.name,
13
+ displayName: a.displayName,
14
+ }));
15
+ }
16
+ async listProperties(accountId) {
17
+ const res = await this.analyticsAdmin.properties.list({
18
+ filter: `parent:accounts/${accountId}`,
19
+ });
20
+ return (res.data.properties || []).map((p) => ({
21
+ name: p.name,
22
+ displayName: p.displayName,
23
+ propertyId: p.name?.replace("properties/", ""),
24
+ }));
25
+ }
26
+ async runReport(params) {
27
+ const request = {
28
+ dateRanges: [{ startDate: params.startDate, endDate: params.endDate }],
29
+ metrics: params.metrics.map((m) => ({ name: m })),
30
+ limit: String(params.limit || 100),
31
+ };
32
+ if (params.dimensions?.length) {
33
+ request.dimensions = params.dimensions.map((d) => ({ name: d }));
34
+ }
35
+ if (params.dimensionFilters?.length) {
36
+ request.dimensionFilter = this.buildDimensionFilter(params.dimensionFilters);
37
+ }
38
+ if (params.orderBy) {
39
+ const isMetric = params.metrics.includes(params.orderBy);
40
+ request.orderBys = [
41
+ {
42
+ desc: params.orderDesc ?? true,
43
+ ...(isMetric
44
+ ? { metric: { metricName: params.orderBy } }
45
+ : { dimension: { dimensionName: params.orderBy } }),
46
+ },
47
+ ];
48
+ }
49
+ const res = await this.analyticsData.properties.runReport({
50
+ property: `properties/${params.propertyId}`,
51
+ requestBody: request,
52
+ });
53
+ return this.formatReportResponse(res.data);
54
+ }
55
+ // --- 편의 도구들 ---
56
+ async getTopPages(propertyId, startDate, endDate, limit = 20) {
57
+ return this.runReport({
58
+ propertyId,
59
+ startDate,
60
+ endDate,
61
+ metrics: ["screenPageViews", "sessions", "averageSessionDuration", "bounceRate"],
62
+ dimensions: ["pagePath", "pageTitle"],
63
+ limit,
64
+ orderBy: "screenPageViews",
65
+ });
66
+ }
67
+ async getTrafficSources(propertyId, startDate, endDate, limit = 20) {
68
+ return this.runReport({
69
+ propertyId,
70
+ startDate,
71
+ endDate,
72
+ metrics: ["sessions", "totalUsers", "newUsers", "bounceRate"],
73
+ dimensions: ["sessionSource", "sessionMedium"],
74
+ limit,
75
+ orderBy: "sessions",
76
+ });
77
+ }
78
+ async getUserOverview(propertyId, startDate, endDate) {
79
+ return this.runReport({
80
+ propertyId,
81
+ startDate,
82
+ endDate,
83
+ metrics: [
84
+ "totalUsers",
85
+ "newUsers",
86
+ "sessions",
87
+ "screenPageViews",
88
+ "averageSessionDuration",
89
+ "bounceRate",
90
+ "sessionsPerUser",
91
+ ],
92
+ });
93
+ }
94
+ async getUsersByCountry(propertyId, startDate, endDate, limit = 20) {
95
+ return this.runReport({
96
+ propertyId,
97
+ startDate,
98
+ endDate,
99
+ metrics: ["totalUsers", "sessions", "screenPageViews"],
100
+ dimensions: ["country"],
101
+ limit,
102
+ orderBy: "totalUsers",
103
+ });
104
+ }
105
+ async getUsersByDevice(propertyId, startDate, endDate) {
106
+ return this.runReport({
107
+ propertyId,
108
+ startDate,
109
+ endDate,
110
+ metrics: ["totalUsers", "sessions", "screenPageViews", "averageSessionDuration"],
111
+ dimensions: ["deviceCategory"],
112
+ orderBy: "totalUsers",
113
+ });
114
+ }
115
+ async getPagesByDate(propertyId, startDate, endDate) {
116
+ return this.runReport({
117
+ propertyId,
118
+ startDate,
119
+ endDate,
120
+ metrics: ["screenPageViews", "sessions", "totalUsers"],
121
+ dimensions: ["date"],
122
+ orderBy: "date",
123
+ orderDesc: false,
124
+ });
125
+ }
126
+ async getRealtimeReport(propertyId) {
127
+ const res = await this.analyticsData.properties.runRealtimeReport({
128
+ property: `properties/${propertyId}`,
129
+ requestBody: {
130
+ metrics: [
131
+ { name: "activeUsers" },
132
+ { name: "screenPageViews" },
133
+ ],
134
+ dimensions: [
135
+ { name: "pagePath" },
136
+ ],
137
+ limit: "20",
138
+ },
139
+ });
140
+ return this.formatReportResponse(res.data);
141
+ }
142
+ // --- 캠페인 분석 ---
143
+ async getCampaignPerformance(propertyId, startDate, endDate, limit = 20, campaignName) {
144
+ const filters = [];
145
+ if (campaignName) {
146
+ filters.push({
147
+ fieldName: "sessionCampaignName",
148
+ matchType: "CONTAINS",
149
+ value: campaignName,
150
+ });
151
+ }
152
+ return this.runReport({
153
+ propertyId,
154
+ startDate,
155
+ endDate,
156
+ metrics: [
157
+ "sessions",
158
+ "totalUsers",
159
+ "newUsers",
160
+ "screenPageViews",
161
+ "averageSessionDuration",
162
+ "bounceRate",
163
+ "conversions",
164
+ ],
165
+ dimensions: ["sessionCampaignName", "sessionSource", "sessionMedium"],
166
+ dimensionFilters: filters.length ? filters : undefined,
167
+ limit,
168
+ orderBy: "sessions",
169
+ });
170
+ }
171
+ async getUtmBreakdown(propertyId, startDate, endDate, limit = 30) {
172
+ return this.runReport({
173
+ propertyId,
174
+ startDate,
175
+ endDate,
176
+ metrics: ["sessions", "totalUsers", "newUsers", "bounceRate", "conversions"],
177
+ dimensions: [
178
+ "sessionCampaignName",
179
+ "sessionSource",
180
+ "sessionMedium",
181
+ "sessionManualAdContent",
182
+ "sessionGoogleAdsKeyword",
183
+ ],
184
+ limit,
185
+ orderBy: "sessions",
186
+ });
187
+ }
188
+ async getCampaignComparison(propertyId, startDate, endDate, campaignNames) {
189
+ const results = {};
190
+ for (const name of campaignNames) {
191
+ results[name] = await this.runReport({
192
+ propertyId,
193
+ startDate,
194
+ endDate,
195
+ metrics: [
196
+ "sessions",
197
+ "totalUsers",
198
+ "newUsers",
199
+ "screenPageViews",
200
+ "averageSessionDuration",
201
+ "bounceRate",
202
+ "conversions",
203
+ ],
204
+ dimensions: ["sessionCampaignName"],
205
+ dimensionFilters: [
206
+ {
207
+ fieldName: "sessionCampaignName",
208
+ matchType: "EXACT",
209
+ value: name,
210
+ },
211
+ ],
212
+ });
213
+ }
214
+ return results;
215
+ }
216
+ // --- 메타데이터 ---
217
+ async getMetadata(propertyId) {
218
+ const res = await this.analyticsData.properties.getMetadata({
219
+ name: `properties/${propertyId}/metadata`,
220
+ });
221
+ const dimensions = (res.data.dimensions || []).map((d) => ({
222
+ apiName: d.apiName,
223
+ uiName: d.uiName,
224
+ category: d.category,
225
+ description: d.description,
226
+ }));
227
+ const metrics = (res.data.metrics || []).map((m) => ({
228
+ apiName: m.apiName,
229
+ uiName: m.uiName,
230
+ category: m.category,
231
+ description: m.description,
232
+ type: m.type,
233
+ }));
234
+ return { dimensions, metrics };
235
+ }
236
+ async searchMetadata(propertyId, keyword, type) {
237
+ const metadata = await this.getMetadata(propertyId);
238
+ const lower = keyword.toLowerCase();
239
+ const matchField = (item) => (item.apiName?.toLowerCase().includes(lower)) ||
240
+ (item.uiName?.toLowerCase().includes(lower)) ||
241
+ (item.description?.toLowerCase().includes(lower)) ||
242
+ (item.category?.toLowerCase().includes(lower));
243
+ const result = {};
244
+ if (!type || type === "dimensions") {
245
+ result.dimensions = metadata.dimensions.filter(matchField);
246
+ }
247
+ if (!type || type === "metrics") {
248
+ result.metrics = metadata.metrics.filter(matchField);
249
+ }
250
+ return result;
251
+ }
252
+ async listCategories(propertyId) {
253
+ const metadata = await this.getMetadata(propertyId);
254
+ const dimCategories = [...new Set(metadata.dimensions.map((d) => d.category).filter(Boolean))];
255
+ const metricCategories = [...new Set(metadata.metrics.map((m) => m.category).filter(Boolean))];
256
+ return {
257
+ dimensionCategories: dimCategories,
258
+ metricCategories: metricCategories,
259
+ };
260
+ }
261
+ // --- 내부 유틸 ---
262
+ buildDimensionFilter(filters) {
263
+ if (filters.length === 1) {
264
+ return {
265
+ filter: {
266
+ fieldName: filters[0].fieldName,
267
+ stringFilter: {
268
+ matchType: filters[0].matchType,
269
+ value: filters[0].value,
270
+ caseSensitive: filters[0].caseSensitive ?? false,
271
+ },
272
+ },
273
+ };
274
+ }
275
+ return {
276
+ andGroup: {
277
+ expressions: filters.map((f) => ({
278
+ filter: {
279
+ fieldName: f.fieldName,
280
+ stringFilter: {
281
+ matchType: f.matchType,
282
+ value: f.value,
283
+ caseSensitive: f.caseSensitive ?? false,
284
+ },
285
+ },
286
+ })),
287
+ },
288
+ };
289
+ }
290
+ formatReportResponse(data) {
291
+ const dimensionHeaders = (data.dimensionHeaders || []).map((h) => h.name || "unknown");
292
+ const metricHeaders = (data.metricHeaders || []).map((h) => h.name || "unknown");
293
+ const rows = (data.rows || []).map((row) => {
294
+ const obj = {};
295
+ (row.dimensionValues || []).forEach((v, i) => {
296
+ obj[dimensionHeaders[i]] = v.value || "";
297
+ });
298
+ (row.metricValues || []).forEach((v, i) => {
299
+ obj[metricHeaders[i]] = v.value || "";
300
+ });
301
+ return obj;
302
+ });
303
+ return {
304
+ headers: [...dimensionHeaders, ...metricHeaders],
305
+ rows,
306
+ rowCount: data.rowCount || 0,
307
+ totals: data.totals,
308
+ };
309
+ }
310
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ declare function main(): Promise<void>;
3
+ export { main };
package/dist/index.js ADDED
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { getAuthenticatedClient } from "./auth.js";
6
+ import { GA4Client } from "./ga.js";
7
+ const CLIENT_SECRET_PATH = process.env.GA_CLIENT_SECRET_PATH ||
8
+ new URL("../client_secret.json", import.meta.url).pathname;
9
+ const DEFAULT_PROPERTY_ID = process.env.GA4_PROPERTY_ID || "";
10
+ let ga4 = null;
11
+ async function getGA4() {
12
+ if (!ga4) {
13
+ const auth = await getAuthenticatedClient(CLIENT_SECRET_PATH);
14
+ ga4 = new GA4Client(auth);
15
+ }
16
+ return ga4;
17
+ }
18
+ function resolvePropertyId(propertyId) {
19
+ const id = propertyId || DEFAULT_PROPERTY_ID;
20
+ if (!id) {
21
+ throw new Error("propertyId is required. Pass it as a parameter or set the GA4_PROPERTY_ID environment variable.");
22
+ }
23
+ return id;
24
+ }
25
+ const server = new McpServer({
26
+ name: "ga4-mcp",
27
+ version: "0.2.0",
28
+ });
29
+ const propertyIdSchema = DEFAULT_PROPERTY_ID
30
+ ? z.string().optional().describe("GA4 property ID (uses GA4_PROPERTY_ID env var if omitted)")
31
+ : z.string().describe("GA4 property ID (numeric)");
32
+ const dimensionFilterSchema = z
33
+ .array(z.object({
34
+ fieldName: z.string().describe("Dimension to filter (e.g. pagePath, sessionCampaignName)"),
35
+ matchType: z
36
+ .enum(["EXACT", "BEGINS_WITH", "ENDS_WITH", "CONTAINS", "REGEXP"])
37
+ .describe("Match type"),
38
+ value: z.string().describe("Filter value"),
39
+ caseSensitive: z.boolean().optional().describe("Case sensitive (default false)"),
40
+ }))
41
+ .optional()
42
+ .describe("Dimension filters");
43
+ // --- Basic ---
44
+ server.tool("list_accounts", "List all GA4 accounts", {}, async () => {
45
+ const client = await getGA4();
46
+ const accounts = await client.listAccounts();
47
+ return {
48
+ content: [{ type: "text", text: JSON.stringify(accounts, null, 2) }],
49
+ };
50
+ });
51
+ server.tool("list_properties", "List GA4 properties for a given account", { accountId: z.string().describe("GA4 account ID (numeric)") }, async ({ accountId }) => {
52
+ const client = await getGA4();
53
+ const properties = await client.listProperties(accountId);
54
+ return {
55
+ content: [{ type: "text", text: JSON.stringify(properties, null, 2) }],
56
+ };
57
+ });
58
+ server.tool("run_report", "Run a custom GA4 report with flexible metric/dimension combinations and dimension filters", {
59
+ propertyId: propertyIdSchema,
60
+ startDate: z.string().describe("Start date (YYYY-MM-DD or relative like 7daysAgo, 30daysAgo)"),
61
+ endDate: z.string().describe("End date (YYYY-MM-DD or relative like today, yesterday)"),
62
+ metrics: z
63
+ .array(z.string())
64
+ .describe("Metrics (e.g. totalUsers, sessions, screenPageViews, bounceRate, averageSessionDuration, conversions)"),
65
+ dimensions: z
66
+ .array(z.string())
67
+ .optional()
68
+ .describe("Dimensions (e.g. pagePath, pageTitle, sessionSource, sessionMedium, sessionCampaignName, country, city, date, deviceCategory)"),
69
+ dimensionFilters: dimensionFilterSchema,
70
+ limit: z.number().optional().describe("Max rows (default 100)"),
71
+ orderBy: z.string().optional().describe("Field to sort by"),
72
+ orderDesc: z.boolean().optional().describe("Sort descending (default true)"),
73
+ }, async (params) => {
74
+ const client = await getGA4();
75
+ const result = await client.runReport({
76
+ ...params,
77
+ propertyId: resolvePropertyId(params.propertyId),
78
+ });
79
+ return {
80
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
81
+ };
82
+ });
83
+ // --- Page & Traffic ---
84
+ server.tool("get_top_pages", "Get top pages by pageviews, sessions, avg. duration, and bounce rate", {
85
+ propertyId: propertyIdSchema,
86
+ startDate: z.string().describe("Start date"),
87
+ endDate: z.string().describe("End date"),
88
+ limit: z.number().optional().describe("Max rows (default 20)"),
89
+ }, async ({ propertyId, startDate, endDate, limit }) => {
90
+ const client = await getGA4();
91
+ const result = await client.getTopPages(resolvePropertyId(propertyId), startDate, endDate, limit);
92
+ return {
93
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
94
+ };
95
+ });
96
+ server.tool("get_traffic_sources", "Analyze traffic sources by source/medium with sessions, users, and bounce rate", {
97
+ propertyId: propertyIdSchema,
98
+ startDate: z.string().describe("Start date"),
99
+ endDate: z.string().describe("End date"),
100
+ limit: z.number().optional().describe("Max rows (default 20)"),
101
+ }, async ({ propertyId, startDate, endDate, limit }) => {
102
+ const client = await getGA4();
103
+ const result = await client.getTrafficSources(resolvePropertyId(propertyId), startDate, endDate, limit);
104
+ return {
105
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
106
+ };
107
+ });
108
+ // --- User Analysis ---
109
+ server.tool("get_user_overview", "User overview: total users, new users, sessions, pageviews, avg. duration, bounce rate", {
110
+ propertyId: propertyIdSchema,
111
+ startDate: z.string().describe("Start date"),
112
+ endDate: z.string().describe("End date"),
113
+ }, async ({ propertyId, startDate, endDate }) => {
114
+ const client = await getGA4();
115
+ const result = await client.getUserOverview(resolvePropertyId(propertyId), startDate, endDate);
116
+ return {
117
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
118
+ };
119
+ });
120
+ server.tool("get_users_by_country", "Analyze users by country", {
121
+ propertyId: propertyIdSchema,
122
+ startDate: z.string().describe("Start date"),
123
+ endDate: z.string().describe("End date"),
124
+ limit: z.number().optional().describe("Max rows (default 20)"),
125
+ }, async ({ propertyId, startDate, endDate, limit }) => {
126
+ const client = await getGA4();
127
+ const result = await client.getUsersByCountry(resolvePropertyId(propertyId), startDate, endDate, limit);
128
+ return {
129
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
130
+ };
131
+ });
132
+ server.tool("get_users_by_device", "Analyze users by device category (desktop, mobile, tablet)", {
133
+ propertyId: propertyIdSchema,
134
+ startDate: z.string().describe("Start date"),
135
+ endDate: z.string().describe("End date"),
136
+ }, async ({ propertyId, startDate, endDate }) => {
137
+ const client = await getGA4();
138
+ const result = await client.getUsersByDevice(resolvePropertyId(propertyId), startDate, endDate);
139
+ return {
140
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
141
+ };
142
+ });
143
+ // --- Trends & Realtime ---
144
+ server.tool("get_trend_by_date", "Daily trend analysis: pageviews, sessions, and users over time", {
145
+ propertyId: propertyIdSchema,
146
+ startDate: z.string().describe("Start date"),
147
+ endDate: z.string().describe("End date"),
148
+ }, async ({ propertyId, startDate, endDate }) => {
149
+ const client = await getGA4();
150
+ const result = await client.getPagesByDate(resolvePropertyId(propertyId), startDate, endDate);
151
+ return {
152
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
153
+ };
154
+ });
155
+ server.tool("get_realtime", "Get realtime active users and pageviews", {
156
+ propertyId: propertyIdSchema,
157
+ }, async ({ propertyId }) => {
158
+ const client = await getGA4();
159
+ const result = await client.getRealtimeReport(resolvePropertyId(propertyId));
160
+ return {
161
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
162
+ };
163
+ });
164
+ // --- Campaign Analysis ---
165
+ server.tool("get_campaign_performance", "Campaign performance: sessions, users, conversions, bounce rate per campaign. Supports filtering by campaign name", {
166
+ propertyId: propertyIdSchema,
167
+ startDate: z.string().describe("Start date"),
168
+ endDate: z.string().describe("End date"),
169
+ campaignName: z.string().optional().describe("Filter by campaign name (partial match)"),
170
+ limit: z.number().optional().describe("Max rows (default 20)"),
171
+ }, async ({ propertyId, startDate, endDate, campaignName, limit }) => {
172
+ const client = await getGA4();
173
+ const result = await client.getCampaignPerformance(resolvePropertyId(propertyId), startDate, endDate, limit, campaignName);
174
+ return {
175
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
176
+ };
177
+ });
178
+ server.tool("get_utm_breakdown", "Full UTM parameter breakdown: campaign, source, medium, content, keyword", {
179
+ propertyId: propertyIdSchema,
180
+ startDate: z.string().describe("Start date"),
181
+ endDate: z.string().describe("End date"),
182
+ limit: z.number().optional().describe("Max rows (default 30)"),
183
+ }, async ({ propertyId, startDate, endDate, limit }) => {
184
+ const client = await getGA4();
185
+ const result = await client.getUtmBreakdown(resolvePropertyId(propertyId), startDate, endDate, limit);
186
+ return {
187
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
188
+ };
189
+ });
190
+ server.tool("compare_campaigns", "Compare performance across multiple campaigns side by side", {
191
+ propertyId: propertyIdSchema,
192
+ startDate: z.string().describe("Start date"),
193
+ endDate: z.string().describe("End date"),
194
+ campaignNames: z
195
+ .array(z.string())
196
+ .describe("Campaign names to compare (exact match)"),
197
+ }, async ({ propertyId, startDate, endDate, campaignNames }) => {
198
+ const client = await getGA4();
199
+ const result = await client.getCampaignComparison(resolvePropertyId(propertyId), startDate, endDate, campaignNames);
200
+ return {
201
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
202
+ };
203
+ });
204
+ // --- Metadata ---
205
+ server.tool("get_metadata", "List all available metrics and dimensions. Use this to discover what data you can query", {
206
+ propertyId: propertyIdSchema,
207
+ type: z.enum(["dimensions", "metrics", "both"]).optional().describe("Type to list (default both)"),
208
+ }, async ({ propertyId, type }) => {
209
+ const client = await getGA4();
210
+ const metadata = await client.getMetadata(resolvePropertyId(propertyId));
211
+ const result = type === "dimensions"
212
+ ? { dimensions: metadata.dimensions }
213
+ : type === "metrics"
214
+ ? { metrics: metadata.metrics }
215
+ : metadata;
216
+ return {
217
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
218
+ };
219
+ });
220
+ server.tool("search_metadata", "Search metrics and dimensions by keyword (e.g. 'campaign', 'user', 'page')", {
221
+ propertyId: propertyIdSchema,
222
+ keyword: z.string().describe("Search keyword"),
223
+ type: z
224
+ .enum(["dimensions", "metrics"])
225
+ .optional()
226
+ .describe("Filter by type (searches all if omitted)"),
227
+ }, async ({ propertyId, keyword, type }) => {
228
+ const client = await getGA4();
229
+ const result = await client.searchMetadata(resolvePropertyId(propertyId), keyword, type);
230
+ return {
231
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
232
+ };
233
+ });
234
+ server.tool("list_categories", "List all metric and dimension categories", {
235
+ propertyId: propertyIdSchema,
236
+ }, async ({ propertyId }) => {
237
+ const client = await getGA4();
238
+ const result = await client.listCategories(resolvePropertyId(propertyId));
239
+ return {
240
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
241
+ };
242
+ });
243
+ // --- Start ---
244
+ async function main() {
245
+ const transport = new StdioServerTransport();
246
+ await server.connect(transport);
247
+ }
248
+ main().catch((err) => {
249
+ console.error("Fatal error:", err);
250
+ process.exit(1);
251
+ });
252
+ export { main };
@@ -0,0 +1,122 @@
1
+ # ga4-mcp
2
+
3
+ Google Analytics 4 데이터를 Claude Desktop에서 바로 조회·분석할 수 있는 MCP 서버입니다.
4
+
5
+ ## 기능
6
+
7
+ ### 기본 조회
8
+ | 도구 | 설명 |
9
+ |---|---|
10
+ | `list_accounts` | GA4 계정 목록 조회 |
11
+ | `list_properties` | 계정 내 속성(Property) 목록 조회 |
12
+ | `run_report` | 커스텀 리포트 실행 (메트릭/디멘션 자유 조합, 디멘션 필터 지원) |
13
+
14
+ ### 페이지/트래픽 분석
15
+ | 도구 | 설명 |
16
+ |---|---|
17
+ | `get_top_pages` | 인기 페이지 (페이지뷰, 세션, 체류시간, 이탈률) |
18
+ | `get_traffic_sources` | 트래픽 소스 분석 (소스/매체별) |
19
+
20
+ ### 사용자 분석
21
+ | 도구 | 설명 |
22
+ |---|---|
23
+ | `get_user_overview` | 사용자 개요 (총 사용자, 신규, 세션, 페이지뷰 등) |
24
+ | `get_users_by_country` | 국가별 사용자 분석 |
25
+ | `get_users_by_device` | 기기별 사용자 분석 (desktop, mobile, tablet) |
26
+
27
+ ### 트렌드/실시간
28
+ | 도구 | 설명 |
29
+ |---|---|
30
+ | `get_trend_by_date` | 일별 트렌드 (페이지뷰, 세션, 사용자 추이) |
31
+ | `get_realtime` | 실시간 활성 사용자 및 페이지뷰 |
32
+
33
+ ### 캠페인 분석
34
+ | 도구 | 설명 |
35
+ |---|---|
36
+ | `get_campaign_performance` | 캠페인 성과 분석 (세션, 사용자, 전환, 이탈률). 캠페인명 필터 가능 |
37
+ | `get_utm_breakdown` | UTM 파라미터 전체 분석 (campaign, source, medium, content, keyword) |
38
+ | `compare_campaigns` | 여러 캠페인 성과 비교 |
39
+
40
+ ### 메타데이터
41
+ | 도구 | 설명 |
42
+ |---|---|
43
+ | `get_metadata` | 사용 가능한 모든 메트릭/디멘션 목록 조회 |
44
+ | `search_metadata` | 키워드로 메트릭/디멘션 검색 |
45
+ | `list_categories` | 메트릭/디멘션 카테고리 목록 |
46
+
47
+ ## 사전 준비
48
+
49
+ ### Google Cloud Console 설정
50
+
51
+ 1. [Google Cloud Console](https://console.cloud.google.com/)에서 프로젝트 생성 또는 선택
52
+ 2. **APIs & Services → Library** → "Google Analytics Data API" 활성화
53
+ 3. **APIs & Services → Library** → "Google Analytics Admin API" 활성화
54
+ 4. **APIs & Services → Credentials** → **OAuth 2.0 Client ID** 생성 (유형: Desktop app)
55
+ 5. JSON 다운로드 → `client_secret.json`으로 저장
56
+
57
+ ## 설치 및 실행
58
+
59
+ ### Claude Desktop 설정
60
+
61
+ `claude_desktop_config.json`에 추가:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "ga": {
67
+ "command": "npx",
68
+ "args": ["-y", "ga4-mcp"],
69
+ "env": {
70
+ "GA_CLIENT_SECRET_PATH": "/path/to/client_secret.json",
71
+ "GA4_PROPERTY_ID": "123456789"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ > Windows의 경우 경로 예시: `"C:\\Users\\사용자명\\client_secret.json"`
79
+
80
+ ### 환경변수
81
+
82
+ | 변수 | 필수 | 설명 |
83
+ |---|---|---|
84
+ | `GA_CLIENT_SECRET_PATH` | 예 | OAuth client_secret.json 파일 경로 |
85
+ | `GA4_PROPERTY_ID` | 아니오 | 기본 GA4 속성 ID. 설정하면 매번 propertyId를 입력하지 않아도 됨 |
86
+
87
+ ### 첫 실행
88
+
89
+ Claude Desktop을 재시작하면 최초 1회 브라우저가 열리며 Google 로그인을 요청합니다.
90
+ 로그인 완료 후 토큰이 자동 저장되어 이후에는 별도 인증 없이 사용 가능합니다.
91
+
92
+ ## 사용 예시
93
+
94
+ Claude Desktop에서 자연어로 질문하면 됩니다:
95
+
96
+ - "우리 사이트 지난 30일 인기 페이지 보여줘"
97
+ - "이번 달 트래픽 소스 분석해줘"
98
+ - "지난주 대비 이번주 사용자 수 비교해줘"
99
+ - "국가별 사용자 분포 알려줘"
100
+ - "지금 실시간 접속자 몇 명이야?"
101
+ - "spring_sale 캠페인 성과 보여줘"
102
+ - "UTM 파라미터별 유입 현황 분석해줘"
103
+ - "A캠페인이랑 B캠페인 성과 비교해줘"
104
+ - "GA4에서 campaign 관련 디멘션 뭐가 있어?"
105
+
106
+ ## 디멘션 필터
107
+
108
+ `run_report`에서 디멘션 필터를 사용하면 특정 조건의 데이터만 조회할 수 있습니다:
109
+
110
+ ```
111
+ "지난 30일 /blog로 시작하는 페이지만 보여줘"
112
+ → dimensionFilter: { fieldName: "pagePath", matchType: "BEGINS_WITH", value: "/blog" }
113
+
114
+ "google에서 유입된 트래픽만 분석해줘"
115
+ → dimensionFilter: { fieldName: "sessionSource", matchType: "EXACT", value: "google" }
116
+ ```
117
+
118
+ 지원하는 매칭 방식: `EXACT`, `BEGINS_WITH`, `ENDS_WITH`, `CONTAINS`, `REGEXP`
119
+
120
+ ## 라이선스
121
+
122
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@dongsik/ga4-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Google Analytics 4 MCP Server",
5
+ "type": "module",
6
+ "bin": {
7
+ "ga-mcp": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node dist/index.js"
12
+ },
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.0.0",
15
+ "googleapis": "^144.0.0",
16
+ "open": "^10.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.0.0",
20
+ "typescript": "^5.5.0"
21
+ }
22
+ }