gtm-mcp-server 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Osama Humayun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,323 @@
1
+ # GTM MCP Server
2
+
3
+ Secure Model Context Protocol server for the Google Tag Manager API. It is read-only by default, with optional write tools (create/update only — **no delete**).
4
+
5
+ ## Quick start for team members (npx + Claude Code)
6
+
7
+ This package is published **publicly** to the npm registry. You do **not** need to clone, build, or authenticate anything — Claude Code launches the server on demand via `npx`.
8
+
9
+ 1. Get your own Google OAuth credentials (see [OAuth setup](#setup) below — each person uses their own Google account that has GTM access). You need three values: `GTM_OAUTH_CLIENT_ID`, `GTM_OAUTH_CLIENT_SECRET`, `GTM_OAUTH_REFRESH_TOKEN`.
10
+
11
+ 2. Add this to your project's `.mcp.json` (keep it out of git — it holds your secrets):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "gtm": {
17
+ "command": "npx",
18
+ "args": ["-y", "gtm-mcp-server"],
19
+ "env": {
20
+ "GTM_ENABLE_WRITE_TOOLS": "true",
21
+ "GTM_OAUTH_CLIENT_ID": "your-client-id",
22
+ "GTM_OAUTH_CLIENT_SECRET": "your-client-secret",
23
+ "GTM_OAUTH_REFRESH_TOKEN": "your-refresh-token"
24
+ }
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ 3. Restart Claude Code. The `gtm` MCP connects automatically and exposes the `gtm_*` tools.
31
+
32
+ Notes:
33
+ - Set `GTM_ENABLE_WRITE_TOOLS` to `"false"` if you only want read/list tools. Write tools are create/update only — there is **no delete tool by design**.
34
+ - Each member supplies their own OAuth credentials. No secret is shared.
35
+ - Google may expire a test-app refresh token after ~7 days; re-run the OAuth flow to get a fresh one (or publish your OAuth consent screen to Production).
36
+
37
+ ## Publishing (maintainer)
38
+
39
+ The package is published publicly to the npm registry.
40
+
41
+ 1. Log in to npm once:
42
+
43
+ ```bash
44
+ npm login
45
+ ```
46
+
47
+ 2. Publish (the `prepare` script builds `dist` first):
48
+
49
+ ```bash
50
+ npm publish
51
+ ```
52
+
53
+ 3. For updates, bump the version and publish again:
54
+
55
+ ```bash
56
+ npm version patch
57
+ npm publish
58
+ ```
59
+
60
+ ## Features
61
+
62
+ - Express server with MCP Streamable HTTP transport at `/mcp`
63
+ - Google Tag Manager API v2 integration
64
+ - OAuth refresh token authentication, with optional service account JSON authentication
65
+ - Read-only GTM scope by default: `https://www.googleapis.com/auth/tagmanager.readonly`
66
+ - Optional write scope when enabled: `https://www.googleapis.com/auth/tagmanager.edit.containers`
67
+ - MCP tools:
68
+ - `gtm_list_accounts`
69
+ - `gtm_list_containers`
70
+ - `gtm_list_tags`
71
+ - `gtm_list_triggers`
72
+ - `gtm_list_variables`
73
+ - `gtm_create_tag` when `GTM_ENABLE_WRITE_TOOLS=true`
74
+ - `gtm_create_trigger` when `GTM_ENABLE_WRITE_TOOLS=true`
75
+ - `gtm_create_variable` when `GTM_ENABLE_WRITE_TOOLS=true`
76
+ - Server-side credential handling only. No frontend credential exposure.
77
+ - Helmet, rate limiting, DNS rebinding host validation, restricted default CORS, sanitized errors.
78
+
79
+ ## Setup
80
+
81
+ 1. Install dependencies:
82
+
83
+ ```bash
84
+ npm install
85
+ ```
86
+
87
+ 2. Create `.env` from `.env.example`.
88
+
89
+ 3. Create an OAuth client in Google Cloud:
90
+
91
+ - Application type: `Web application`
92
+ - Authorized JavaScript origins: leave empty
93
+ - Authorized redirect URI: `http://localhost:3000/oauth/google/callback`
94
+
95
+ 4. Add the OAuth client values to `.env`:
96
+
97
+ ```env
98
+ GTM_ENABLE_WRITE_TOOLS=false
99
+ GTM_OAUTH_CLIENT_ID=...
100
+ GTM_OAUTH_CLIENT_SECRET=...
101
+ GTM_OAUTH_REDIRECT_URI=http://localhost:3000/oauth/google/callback
102
+ GTM_OAUTH_REFRESH_TOKEN=
103
+ ```
104
+
105
+ 5. Start the local server:
106
+
107
+ ```bash
108
+ npm run dev
109
+ ```
110
+
111
+ 6. Open this URL in your browser and sign in with the Google account that has GTM access:
112
+
113
+ ```text
114
+ http://localhost:3000/oauth/google/start
115
+ ```
116
+
117
+ 7. After consent, the server terminal prints a refresh token. Add it to `.env`:
118
+
119
+ ```env
120
+ GTM_OAUTH_REFRESH_TOKEN=...
121
+ ```
122
+
123
+ 8. Restart the server.
124
+
125
+ ## Create Tags
126
+
127
+ By default the server is read-only. To create tags, set:
128
+
129
+ ```env
130
+ GTM_ENABLE_WRITE_TOOLS=true
131
+ ```
132
+
133
+ Then run OAuth setup again from `/oauth/google/start` and replace `GTM_OAUTH_REFRESH_TOKEN`, because the old read-only token does not have write scope.
134
+
135
+ Postman body:
136
+
137
+ ```json
138
+ {
139
+ "jsonrpc": "2.0",
140
+ "id": 5,
141
+ "method": "tools/call",
142
+ "params": {
143
+ "name": "gtm_create_tag",
144
+ "arguments": {
145
+ "accountId": "123456",
146
+ "containerId": "78910",
147
+ "workspaceId": "3",
148
+ "name": "Postman test tag",
149
+ "type": "googtag",
150
+ "parameter": [
151
+ {
152
+ "type": "template",
153
+ "key": "tagId",
154
+ "value": "11212121"
155
+ }
156
+ ],
157
+ "firingTriggerId": ["2147479573"],
158
+ "tagFiringOption": "oncePerEvent"
159
+ }
160
+ }
161
+ }
162
+ ```
163
+
164
+ Create trigger example:
165
+
166
+ ```json
167
+ {
168
+ "jsonrpc": "2.0",
169
+ "id": 6,
170
+ "method": "tools/call",
171
+ "params": {
172
+ "name": "gtm_create_trigger",
173
+ "arguments": {
174
+ "accountId": "123456",
175
+ "containerId": "78910",
176
+ "workspaceId": "3",
177
+ "name": "Postman click trigger",
178
+ "type": "click",
179
+ "filter": [
180
+ {
181
+ "type": "contains",
182
+ "parameter": [
183
+ {
184
+ "type": "template",
185
+ "key": "arg0",
186
+ "value": "{{Click Text}}"
187
+ },
188
+ {
189
+ "type": "template",
190
+ "key": "arg1",
191
+ "value": "Login"
192
+ }
193
+ ]
194
+ }
195
+ ]
196
+ }
197
+ }
198
+ }
199
+ ```
200
+
201
+ Create variable example:
202
+
203
+ ```json
204
+ {
205
+ "jsonrpc": "2.0",
206
+ "id": 7,
207
+ "method": "tools/call",
208
+ "params": {
209
+ "name": "gtm_create_variable",
210
+ "arguments": {
211
+ "accountId": "123456",
212
+ "containerId": "78910",
213
+ "workspaceId": "3",
214
+ "name": "Postman constant variable",
215
+ "type": "c",
216
+ "parameter": [
217
+ {
218
+ "type": "template",
219
+ "key": "value",
220
+ "value": "hello"
221
+ }
222
+ ]
223
+ }
224
+ }
225
+ }
226
+ ```
227
+
228
+ ## Service Account Alternative
229
+
230
+ Use this only if GTM accepts your service account email in user management.
231
+
232
+ Put the service account JSON outside public/frontend paths. The JSON file is ignored by git by default:
233
+
234
+ ```env
235
+ GTM_SERVICE_ACCOUNT_KEY_FILE=./service-account.json
236
+ ```
237
+
238
+ Alternatively, provide a base64-encoded JSON secret:
239
+
240
+ ```env
241
+ GTM_SERVICE_ACCOUNT_JSON_BASE64=...
242
+ ```
243
+
244
+ Grant the service account view-only access to the relevant GTM accounts/containers.
245
+
246
+ ## Production
247
+
248
+ ```bash
249
+ npm run build
250
+ npm start
251
+ ```
252
+
253
+ Bind `HOST` deliberately. The default is `127.0.0.1` for local-only access. If exposing the server remotely, terminate TLS in front of it, restrict network access, set `ALLOWED_HOSTS` to the expected hostnames, and set `ALLOWED_ORIGINS` only for trusted browser clients.
254
+
255
+ ## Tool Inputs
256
+
257
+ `gtm_list_accounts` takes no input.
258
+
259
+ `gtm_list_containers`:
260
+
261
+ ```json
262
+ {
263
+ "accountId": "123456"
264
+ }
265
+ ```
266
+
267
+ `gtm_list_tags`, `gtm_list_triggers`, and `gtm_list_variables`:
268
+
269
+ ```json
270
+ {
271
+ "accountId": "123456",
272
+ "containerId": "78910",
273
+ "workspaceId": "1"
274
+ }
275
+ ```
276
+
277
+ `workspaceId` is optional. If omitted, the server lists entities from every workspace in the container.
278
+
279
+ Postman trigger list body:
280
+
281
+ ```json
282
+ {
283
+ "jsonrpc": "2.0",
284
+ "id": 8,
285
+ "method": "tools/call",
286
+ "params": {
287
+ "name": "gtm_list_triggers",
288
+ "arguments": {
289
+ "accountId": "123456",
290
+ "containerId": "78910",
291
+ "workspaceId": "3"
292
+ }
293
+ }
294
+ }
295
+ ```
296
+
297
+ Postman variable list body:
298
+
299
+ ```json
300
+ {
301
+ "jsonrpc": "2.0",
302
+ "id": 9,
303
+ "method": "tools/call",
304
+ "params": {
305
+ "name": "gtm_list_variables",
306
+ "arguments": {
307
+ "accountId": "123456",
308
+ "containerId": "78910",
309
+ "workspaceId": "3"
310
+ }
311
+ }
312
+ }
313
+ ```
314
+
315
+ ## Security Notes
316
+
317
+ - Keep service account JSON files out of git, frontend directories, static hosting, and client bundles.
318
+ - Keep OAuth client secrets and refresh tokens server-side only.
319
+ - Use a Google account or service account with the minimum GTM permissions needed for read-only access.
320
+ - Keep `GTM_ENABLE_WRITE_TOOLS=false` unless GTM mutations are intentionally needed.
321
+ - Set `ALLOWED_HOSTS` when binding to a non-localhost interface.
322
+ - Do not enable broad CORS. Leave `ALLOWED_ORIGINS` empty unless a specific trusted browser client needs access.
323
+ - Rotate the service account key if it is ever copied into logs, tickets, browser code, or public repositories.
@@ -0,0 +1,101 @@
1
+ import "dotenv/config";
2
+ import { z } from "zod/v4";
3
+ const booleanStringSchema = z
4
+ .string()
5
+ .optional()
6
+ .default("false")
7
+ .transform((value, ctx) => {
8
+ const normalized = value.trim().toLowerCase();
9
+ if (["true", "1", "yes", "on"].includes(normalized)) {
10
+ return true;
11
+ }
12
+ if (["false", "0", "no", "off", ""].includes(normalized)) {
13
+ return false;
14
+ }
15
+ ctx.addIssue({
16
+ code: "custom",
17
+ message: "Expected boolean value: true, false, 1, 0, yes, no, on, or off."
18
+ });
19
+ return z.NEVER;
20
+ });
21
+ const envSchema = z
22
+ .object({
23
+ HOST: z.string().min(1).default("127.0.0.1"),
24
+ PORT: z.coerce.number().int().min(1).max(65535).default(3000),
25
+ MCP_PATH: z.string().regex(/^\//).default("/mcp"),
26
+ LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
27
+ ALLOWED_HOSTS: z.string().optional().default(""),
28
+ ALLOWED_ORIGINS: z.string().optional().default(""),
29
+ GTM_ENABLE_WRITE_TOOLS: booleanStringSchema,
30
+ GTM_SERVICE_ACCOUNT_KEY_FILE: z.string().optional(),
31
+ GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(),
32
+ GTM_SERVICE_ACCOUNT_JSON_BASE64: z.string().optional(),
33
+ GTM_OAUTH_CLIENT_ID: z.string().optional(),
34
+ GTM_OAUTH_CLIENT_SECRET: z.string().optional(),
35
+ GTM_OAUTH_REFRESH_TOKEN: z.string().optional(),
36
+ GTM_OAUTH_REDIRECT_URI: z.string().url().optional()
37
+ })
38
+ .transform((env) => {
39
+ const serviceAccountKeyFile = env.GTM_SERVICE_ACCOUNT_KEY_FILE || env.GOOGLE_APPLICATION_CREDENTIALS;
40
+ const serviceAccountJsonBase64 = env.GTM_SERVICE_ACCOUNT_JSON_BASE64;
41
+ return {
42
+ host: env.HOST,
43
+ port: env.PORT,
44
+ mcpPath: env.MCP_PATH,
45
+ logLevel: env.LOG_LEVEL,
46
+ allowedHosts: env.ALLOWED_HOSTS.split(",")
47
+ .map((host) => host.trim())
48
+ .filter(Boolean),
49
+ allowedOrigins: env.ALLOWED_ORIGINS.split(",")
50
+ .map((origin) => origin.trim())
51
+ .filter(Boolean),
52
+ enableWriteTools: env.GTM_ENABLE_WRITE_TOOLS,
53
+ google: {
54
+ serviceAccountKeyFile,
55
+ serviceAccountJsonBase64,
56
+ oauthClientId: env.GTM_OAUTH_CLIENT_ID,
57
+ oauthClientSecret: env.GTM_OAUTH_CLIENT_SECRET,
58
+ oauthRefreshToken: env.GTM_OAUTH_REFRESH_TOKEN,
59
+ oauthRedirectUri: env.GTM_OAUTH_REDIRECT_URI ??
60
+ `http://${env.HOST}:${env.PORT}/oauth/google/callback`,
61
+ scopes: env.GTM_ENABLE_WRITE_TOOLS
62
+ ? ["https://www.googleapis.com/auth/tagmanager.edit.containers"]
63
+ : ["https://www.googleapis.com/auth/tagmanager.readonly"]
64
+ }
65
+ };
66
+ })
67
+ .superRefine((config, ctx) => {
68
+ const hasKeyFile = Boolean(config.google.serviceAccountKeyFile);
69
+ const hasInlineJson = Boolean(config.google.serviceAccountJsonBase64);
70
+ const hasServiceAccount = hasKeyFile || hasInlineJson;
71
+ const hasCompleteOAuth = Boolean(config.google.oauthClientId &&
72
+ config.google.oauthClientSecret &&
73
+ config.google.oauthRefreshToken);
74
+ const hasOAuthSetup = Boolean(config.google.oauthClientId && config.google.oauthClientSecret && !config.google.oauthRefreshToken);
75
+ if (hasKeyFile && hasInlineJson) {
76
+ ctx.addIssue({
77
+ code: "custom",
78
+ message: "Set only one service account source: GTM_SERVICE_ACCOUNT_KEY_FILE/GOOGLE_APPLICATION_CREDENTIALS or GTM_SERVICE_ACCOUNT_JSON_BASE64."
79
+ });
80
+ }
81
+ if (!hasServiceAccount && !hasCompleteOAuth && !hasOAuthSetup) {
82
+ ctx.addIssue({
83
+ code: "custom",
84
+ message: "Set service account auth or OAuth auth. For OAuth setup, set GTM_OAUTH_CLIENT_ID and GTM_OAUTH_CLIENT_SECRET."
85
+ });
86
+ }
87
+ if (hasServiceAccount && (hasCompleteOAuth || hasOAuthSetup)) {
88
+ ctx.addIssue({
89
+ code: "custom",
90
+ message: "Use either service account auth or OAuth auth, not both."
91
+ });
92
+ }
93
+ });
94
+ export function loadConfig() {
95
+ const result = envSchema.safeParse(process.env);
96
+ if (!result.success) {
97
+ const details = z.prettifyError(result.error);
98
+ throw new Error(`Invalid environment configuration:\n${details}`);
99
+ }
100
+ return result.data;
101
+ }
@@ -0,0 +1,36 @@
1
+ import { google } from "googleapis";
2
+ import { AppError } from "../shared/errors.js";
3
+ function parseBase64ServiceAccount(value) {
4
+ try {
5
+ const decoded = Buffer.from(value, "base64").toString("utf8");
6
+ const credentials = JSON.parse(decoded);
7
+ if (!credentials.client_email || !credentials.private_key) {
8
+ throw new Error("Missing client_email or private_key.");
9
+ }
10
+ return credentials;
11
+ }
12
+ catch {
13
+ throw new AppError("Invalid GTM_SERVICE_ACCOUNT_JSON_BASE64 value.", 500, true);
14
+ }
15
+ }
16
+ export function createGoogleAuth(config) {
17
+ if (config.google.oauthClientId && config.google.oauthClientSecret) {
18
+ const oauthClient = new google.auth.OAuth2(config.google.oauthClientId, config.google.oauthClientSecret, config.google.oauthRedirectUri);
19
+ if (config.google.oauthRefreshToken) {
20
+ oauthClient.setCredentials({
21
+ refresh_token: config.google.oauthRefreshToken
22
+ });
23
+ }
24
+ return oauthClient;
25
+ }
26
+ if (config.google.serviceAccountJsonBase64) {
27
+ return new google.auth.GoogleAuth({
28
+ credentials: parseBase64ServiceAccount(config.google.serviceAccountJsonBase64),
29
+ scopes: [...config.google.scopes]
30
+ });
31
+ }
32
+ return new google.auth.GoogleAuth({
33
+ keyFile: config.google.serviceAccountKeyFile,
34
+ scopes: [...config.google.scopes]
35
+ });
36
+ }
@@ -0,0 +1,37 @@
1
+ import crypto from "node:crypto";
2
+ import { google } from "googleapis";
3
+ import { AppError } from "../shared/errors.js";
4
+ const stateTtlMs = 10 * 60 * 1000;
5
+ const pendingStates = new Map();
6
+ function cleanupExpiredStates() {
7
+ const now = Date.now();
8
+ for (const [state, expiresAt] of pendingStates.entries()) {
9
+ if (expiresAt <= now) {
10
+ pendingStates.delete(state);
11
+ }
12
+ }
13
+ }
14
+ export function createOAuthSetupClient(config) {
15
+ if (!config.google.oauthClientId || !config.google.oauthClientSecret) {
16
+ throw new AppError("OAuth client ID and secret are not configured.", 500, true);
17
+ }
18
+ return new google.auth.OAuth2(config.google.oauthClientId, config.google.oauthClientSecret, config.google.oauthRedirectUri);
19
+ }
20
+ export function generateOAuthStartUrl(config) {
21
+ cleanupExpiredStates();
22
+ const state = crypto.randomBytes(32).toString("hex");
23
+ pendingStates.set(state, Date.now() + stateTtlMs);
24
+ const oauthClient = createOAuthSetupClient(config);
25
+ return oauthClient.generateAuthUrl({
26
+ access_type: "offline",
27
+ prompt: "consent",
28
+ scope: [...config.google.scopes],
29
+ state
30
+ });
31
+ }
32
+ export function consumeOAuthState(state) {
33
+ cleanupExpiredStates();
34
+ const expiresAt = pendingStates.get(state);
35
+ pendingStates.delete(state);
36
+ return Boolean(expiresAt && expiresAt > Date.now());
37
+ }
@@ -0,0 +1,157 @@
1
+ import { google } from "googleapis";
2
+ import { accountPath, containerPath, tagPath, workspacePath } from "./path.js";
3
+ export class GoogleTagManagerService {
4
+ tagmanager;
5
+ constructor(auth) {
6
+ this.tagmanager = google.tagmanager({ version: "v2", auth });
7
+ }
8
+ async listAccounts() {
9
+ const accounts = [];
10
+ let pageToken;
11
+ do {
12
+ const response = await this.tagmanager.accounts.list({ pageToken });
13
+ accounts.push(...(response.data.account ?? []));
14
+ pageToken = response.data.nextPageToken ?? undefined;
15
+ } while (pageToken);
16
+ return accounts;
17
+ }
18
+ async listContainers(accountIdOrPath) {
19
+ const containers = [];
20
+ let pageToken;
21
+ do {
22
+ const response = await this.tagmanager.accounts.containers.list({
23
+ parent: accountPath(accountIdOrPath),
24
+ pageToken
25
+ });
26
+ containers.push(...(response.data.container ?? []));
27
+ pageToken = response.data.nextPageToken ?? undefined;
28
+ } while (pageToken);
29
+ return containers;
30
+ }
31
+ async listWorkspaces(accountIdOrPath, containerIdOrPath) {
32
+ const workspaces = [];
33
+ let pageToken;
34
+ do {
35
+ const response = await this.tagmanager.accounts.containers.workspaces.list({
36
+ parent: containerPath(accountIdOrPath, containerIdOrPath),
37
+ pageToken
38
+ });
39
+ workspaces.push(...(response.data.workspace ?? []));
40
+ pageToken = response.data.nextPageToken ?? undefined;
41
+ } while (pageToken);
42
+ return workspaces;
43
+ }
44
+ async listTags(accountIdOrPath, containerIdOrPath, workspaceIdOrPath) {
45
+ return this.listWorkspaceScopedEntities(accountIdOrPath, containerIdOrPath, workspaceIdOrPath, async (parent, pageToken) => {
46
+ const response = await this.tagmanager.accounts.containers.workspaces.tags.list({
47
+ parent,
48
+ pageToken
49
+ });
50
+ return {
51
+ items: response.data.tag ?? [],
52
+ nextPageToken: response.data.nextPageToken ?? undefined
53
+ };
54
+ });
55
+ }
56
+ async listTriggers(accountIdOrPath, containerIdOrPath, workspaceIdOrPath) {
57
+ return this.listWorkspaceScopedEntities(accountIdOrPath, containerIdOrPath, workspaceIdOrPath, async (parent, pageToken) => {
58
+ const response = await this.tagmanager.accounts.containers.workspaces.triggers.list({
59
+ parent,
60
+ pageToken
61
+ });
62
+ return {
63
+ items: response.data.trigger ?? [],
64
+ nextPageToken: response.data.nextPageToken ?? undefined
65
+ };
66
+ });
67
+ }
68
+ async listVariables(accountIdOrPath, containerIdOrPath, workspaceIdOrPath) {
69
+ return this.listWorkspaceScopedEntities(accountIdOrPath, containerIdOrPath, workspaceIdOrPath, async (parent, pageToken) => {
70
+ const response = await this.tagmanager.accounts.containers.workspaces.variables.list({
71
+ parent,
72
+ pageToken
73
+ });
74
+ return {
75
+ items: response.data.variable ?? [],
76
+ nextPageToken: response.data.nextPageToken ?? undefined
77
+ };
78
+ });
79
+ }
80
+ async createTag(input) {
81
+ const response = await this.tagmanager.accounts.containers.workspaces.tags.create({
82
+ parent: workspacePath(input.accountId, input.containerId, input.workspaceId),
83
+ requestBody: {
84
+ name: input.name,
85
+ type: input.type,
86
+ parameter: input.parameter,
87
+ firingTriggerId: input.firingTriggerId,
88
+ blockingTriggerId: input.blockingTriggerId,
89
+ tagFiringOption: input.tagFiringOption ?? "oncePerEvent"
90
+ }
91
+ });
92
+ return response.data;
93
+ }
94
+ async updateTag(input) {
95
+ const response = await this.tagmanager.accounts.containers.workspaces.tags.update({
96
+ path: tagPath(input.accountId, input.containerId, input.workspaceId, input.tagId),
97
+ fingerprint: input.fingerprint,
98
+ requestBody: {
99
+ name: input.name,
100
+ type: input.type,
101
+ parameter: input.parameter,
102
+ firingTriggerId: input.firingTriggerId,
103
+ blockingTriggerId: input.blockingTriggerId,
104
+ tagFiringOption: input.tagFiringOption
105
+ }
106
+ });
107
+ return response.data;
108
+ }
109
+ async createTrigger(input) {
110
+ const response = await this.tagmanager.accounts.containers.workspaces.triggers.create({
111
+ parent: workspacePath(input.accountId, input.containerId, input.workspaceId),
112
+ requestBody: {
113
+ name: input.name,
114
+ type: input.type,
115
+ filter: input.filter,
116
+ autoEventFilter: input.autoEventFilter,
117
+ customEventFilter: input.customEventFilter,
118
+ parameter: input.parameter
119
+ }
120
+ });
121
+ return response.data;
122
+ }
123
+ async createVariable(input) {
124
+ const response = await this.tagmanager.accounts.containers.workspaces.variables.create({
125
+ parent: workspacePath(input.accountId, input.containerId, input.workspaceId),
126
+ requestBody: {
127
+ name: input.name,
128
+ type: input.type,
129
+ parameter: input.parameter,
130
+ formatValue: input.formatValue
131
+ }
132
+ });
133
+ return response.data;
134
+ }
135
+ async listWorkspaceScopedEntities(accountIdOrPath, containerIdOrPath, workspaceIdOrPath, listPage) {
136
+ const workspaces = workspaceIdOrPath
137
+ ? [{ path: workspacePath(accountIdOrPath, containerIdOrPath, workspaceIdOrPath) }]
138
+ : await this.listWorkspaces(accountIdOrPath, containerIdOrPath);
139
+ // Fetch each workspace's entities concurrently. Pagination within a single
140
+ // workspace stays sequential (page tokens are inherently serial), but the
141
+ // per-workspace fetches no longer block each other — listing across N
142
+ // workspaces costs roughly one round-trip instead of N.
143
+ const results = await Promise.all(workspaces
144
+ .filter((workspace) => Boolean(workspace.path))
145
+ .map(async (workspace) => {
146
+ const items = [];
147
+ let pageToken;
148
+ do {
149
+ const page = await listPage(workspace.path, pageToken);
150
+ items.push(...page.items);
151
+ pageToken = page.nextPageToken;
152
+ } while (pageToken);
153
+ return { workspace, items };
154
+ }));
155
+ return results;
156
+ }
157
+ }
@@ -0,0 +1,20 @@
1
+ export function accountPath(accountIdOrPath) {
2
+ return accountIdOrPath.startsWith("accounts/")
3
+ ? accountIdOrPath
4
+ : `accounts/${accountIdOrPath}`;
5
+ }
6
+ export function containerPath(accountIdOrPath, containerIdOrPath) {
7
+ return containerIdOrPath.startsWith("accounts/")
8
+ ? containerIdOrPath
9
+ : `${accountPath(accountIdOrPath)}/containers/${containerIdOrPath}`;
10
+ }
11
+ export function workspacePath(accountIdOrPath, containerIdOrPath, workspaceIdOrPath) {
12
+ return workspaceIdOrPath.startsWith("accounts/")
13
+ ? workspaceIdOrPath
14
+ : `${containerPath(accountIdOrPath, containerIdOrPath)}/workspaces/${workspaceIdOrPath}`;
15
+ }
16
+ export function tagPath(accountIdOrPath, containerIdOrPath, workspaceIdOrPath, tagIdOrPath) {
17
+ return tagIdOrPath.startsWith("accounts/")
18
+ ? tagIdOrPath
19
+ : `${workspacePath(accountIdOrPath, containerIdOrPath, workspaceIdOrPath)}/tags/${tagIdOrPath}`;
20
+ }
@@ -0,0 +1,111 @@
1
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import cors from "cors";
4
+ import rateLimit from "express-rate-limit";
5
+ import helmet from "helmet";
6
+ import { consumeOAuthState, createOAuthSetupClient, generateOAuthStartUrl } from "../google/oauth-setup.js";
7
+ import { createGtmMcpServer } from "../mcp/server.js";
8
+ import { logError } from "../shared/errors.js";
9
+ import { errorHandler, notFoundHandler } from "./middleware.js";
10
+ function mcpMethodNotAllowed(_req, res) {
11
+ res.status(405).json({
12
+ jsonrpc: "2.0",
13
+ error: {
14
+ code: -32000,
15
+ message: "Method not allowed."
16
+ },
17
+ id: null
18
+ });
19
+ }
20
+ export function createApp(config, gtm) {
21
+ const app = createMcpExpressApp({
22
+ host: config.host,
23
+ allowedHosts: config.allowedHosts.length > 0 ? config.allowedHosts : undefined
24
+ });
25
+ app.disable("x-powered-by");
26
+ app.use(helmet());
27
+ app.use(rateLimit({
28
+ windowMs: 60_000,
29
+ limit: 120,
30
+ standardHeaders: "draft-7",
31
+ legacyHeaders: false
32
+ }));
33
+ if (config.allowedOrigins.length > 0) {
34
+ app.use(cors({
35
+ origin: config.allowedOrigins,
36
+ methods: ["POST", "GET"],
37
+ allowedHeaders: ["Content-Type", "Authorization", "Mcp-Session-Id"],
38
+ maxAge: 600
39
+ }));
40
+ }
41
+ app.get("/healthz", (_req, res) => {
42
+ res.status(200).json({ status: "ok" });
43
+ });
44
+ app.get("/oauth/google/start", (_req, res, next) => {
45
+ try {
46
+ res.redirect(generateOAuthStartUrl(config));
47
+ }
48
+ catch (error) {
49
+ next(error);
50
+ }
51
+ });
52
+ app.get("/oauth/google/callback", async (req, res, next) => {
53
+ try {
54
+ const code = typeof req.query.code === "string" ? req.query.code : undefined;
55
+ const state = typeof req.query.state === "string" ? req.query.state : undefined;
56
+ if (!code || !state || !consumeOAuthState(state)) {
57
+ res.status(400).send("Invalid or expired OAuth callback. Start again from /oauth/google/start.");
58
+ return;
59
+ }
60
+ const oauthClient = createOAuthSetupClient(config);
61
+ const { tokens } = await oauthClient.getToken(code);
62
+ if (!tokens.refresh_token) {
63
+ res
64
+ .status(400)
65
+ .send("Google did not return a refresh token. Revoke this app from your Google Account permissions, then start again.");
66
+ return;
67
+ }
68
+ console.log("\nAdd this to your .env as GTM_OAUTH_REFRESH_TOKEN:");
69
+ console.log(tokens.refresh_token);
70
+ console.log("");
71
+ res
72
+ .status(200)
73
+ .send("OAuth refresh token generated. Check the server terminal and add it to .env as GTM_OAUTH_REFRESH_TOKEN, then restart the server.");
74
+ }
75
+ catch (error) {
76
+ next(error);
77
+ }
78
+ });
79
+ app.post(config.mcpPath, async (req, res) => {
80
+ const server = createGtmMcpServer(gtm, config);
81
+ const transport = new StreamableHTTPServerTransport({
82
+ sessionIdGenerator: undefined
83
+ });
84
+ res.on("close", () => {
85
+ transport.close();
86
+ server.close();
87
+ });
88
+ try {
89
+ await server.connect(transport);
90
+ await transport.handleRequest(req, res, req.body);
91
+ }
92
+ catch (error) {
93
+ logError(error);
94
+ if (!res.headersSent) {
95
+ res.status(500).json({
96
+ jsonrpc: "2.0",
97
+ error: {
98
+ code: -32603,
99
+ message: "Internal server error."
100
+ },
101
+ id: null
102
+ });
103
+ }
104
+ }
105
+ });
106
+ app.get(config.mcpPath, mcpMethodNotAllowed);
107
+ app.delete(config.mcpPath, mcpMethodNotAllowed);
108
+ app.use(notFoundHandler);
109
+ app.use(errorHandler);
110
+ return app;
111
+ }
@@ -0,0 +1,14 @@
1
+ import { AppError, logError } from "../shared/errors.js";
2
+ export const notFoundHandler = (_req, _res, next) => {
3
+ next(new AppError("Not found.", 404, true));
4
+ };
5
+ export const errorHandler = (error, _req, res, _next) => {
6
+ logError(error);
7
+ const statusCode = error instanceof AppError ? error.statusCode : 500;
8
+ const message = error instanceof AppError && error.expose ? error.message : "Internal server error.";
9
+ res.status(statusCode).json({
10
+ error: {
11
+ message
12
+ }
13
+ });
14
+ };
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ import { loadConfig } from "./config/env.js";
2
+ import { createGoogleAuth } from "./google/auth.js";
3
+ import { GoogleTagManagerService } from "./gtm/gtm-service.js";
4
+ import { createApp } from "./http/app.js";
5
+ const config = loadConfig();
6
+ const auth = createGoogleAuth(config);
7
+ const gtm = new GoogleTagManagerService(auth);
8
+ const app = createApp(config, gtm);
9
+ const httpServer = app.listen(config.port, config.host, () => {
10
+ console.log(`GTM MCP server listening on http://${config.host}:${config.port}${config.mcpPath}`);
11
+ });
12
+ function shutdown(signal) {
13
+ console.log(`Received ${signal}; shutting down.`);
14
+ httpServer.close((error) => {
15
+ if (error) {
16
+ console.error(error);
17
+ process.exit(1);
18
+ }
19
+ process.exit(0);
20
+ });
21
+ }
22
+ process.on("SIGINT", shutdown);
23
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerGtmTools } from "./tools.js";
3
+ export function createGtmMcpServer(gtm, config) {
4
+ const server = new McpServer({
5
+ name: "gtm-mcp-server",
6
+ version: "0.1.0"
7
+ }, {
8
+ capabilities: {
9
+ logging: {}
10
+ }
11
+ });
12
+ registerGtmTools(server, gtm, { enableWriteTools: config.enableWriteTools });
13
+ return server;
14
+ }
@@ -0,0 +1,281 @@
1
+ import { z } from "zod/v4";
2
+ import { toSafeErrorMessage } from "../shared/errors.js";
3
+ const gtmParameterSchema = z.lazy(() => z.object({
4
+ type: z
5
+ .string()
6
+ .min(1)
7
+ .describe("GTM parameter type, for example template, boolean, list, or map."),
8
+ key: z.string().min(1).optional().describe("Parameter key."),
9
+ value: z.string().optional().describe("Parameter value for template/string-like parameters."),
10
+ list: z
11
+ .array(gtmParameterSchema)
12
+ .optional()
13
+ .describe("Nested list items when type is list."),
14
+ map: z
15
+ .array(gtmParameterSchema)
16
+ .optional()
17
+ .describe("Nested map entries when type is map.")
18
+ }));
19
+ const gtmConditionSchema = z.object({
20
+ type: z.string().min(1).describe("GTM condition type, for example contains, equals, or cssSelector."),
21
+ parameter: z.array(gtmParameterSchema).optional().describe("Condition parameters.")
22
+ });
23
+ function jsonResult(data) {
24
+ return {
25
+ content: [
26
+ {
27
+ type: "text",
28
+ text: JSON.stringify(data, null, 2)
29
+ }
30
+ ],
31
+ structuredContent: data
32
+ };
33
+ }
34
+ function errorResult(error) {
35
+ return {
36
+ content: [
37
+ {
38
+ type: "text",
39
+ text: toSafeErrorMessage(error)
40
+ }
41
+ ],
42
+ isError: true
43
+ };
44
+ }
45
+ export function registerGtmTools(server, gtm, options) {
46
+ server.registerTool("gtm_list_accounts", {
47
+ title: "List GTM Accounts",
48
+ description: "List Google Tag Manager accounts visible to the configured Google auth identity.",
49
+ inputSchema: {}
50
+ }, async () => {
51
+ try {
52
+ return jsonResult({ accounts: await gtm.listAccounts() });
53
+ }
54
+ catch (error) {
55
+ return errorResult(error);
56
+ }
57
+ });
58
+ server.registerTool("gtm_list_containers", {
59
+ title: "List GTM Containers",
60
+ description: "List Google Tag Manager containers for an account.",
61
+ inputSchema: {
62
+ accountId: z
63
+ .string()
64
+ .min(1)
65
+ .describe("GTM account ID or full account path, for example 123456 or accounts/123456.")
66
+ }
67
+ }, async ({ accountId }) => {
68
+ try {
69
+ return jsonResult({ containers: await gtm.listContainers(accountId) });
70
+ }
71
+ catch (error) {
72
+ return errorResult(error);
73
+ }
74
+ });
75
+ server.registerTool("gtm_list_tags", {
76
+ title: "List GTM Tags",
77
+ description: "List Google Tag Manager tags for a container. If workspaceId is omitted, tags are listed from all workspaces.",
78
+ inputSchema: {
79
+ accountId: z.string().min(1).describe("GTM account ID or full account path."),
80
+ containerId: z.string().min(1).describe("GTM container ID or full container path."),
81
+ workspaceId: z
82
+ .string()
83
+ .min(1)
84
+ .optional()
85
+ .describe("Optional GTM workspace ID or full workspace path.")
86
+ }
87
+ }, async ({ accountId, containerId, workspaceId }) => {
88
+ try {
89
+ return jsonResult({ workspaces: await gtm.listTags(accountId, containerId, workspaceId) });
90
+ }
91
+ catch (error) {
92
+ return errorResult(error);
93
+ }
94
+ });
95
+ server.registerTool("gtm_list_triggers", {
96
+ title: "List GTM Triggers",
97
+ description: "List Google Tag Manager triggers for a container. If workspaceId is omitted, triggers are listed from all workspaces.",
98
+ inputSchema: {
99
+ accountId: z.string().min(1).describe("GTM account ID or full account path."),
100
+ containerId: z.string().min(1).describe("GTM container ID or full container path."),
101
+ workspaceId: z
102
+ .string()
103
+ .min(1)
104
+ .optional()
105
+ .describe("Optional GTM workspace ID or full workspace path.")
106
+ }
107
+ }, async ({ accountId, containerId, workspaceId }) => {
108
+ try {
109
+ return jsonResult({
110
+ workspaces: await gtm.listTriggers(accountId, containerId, workspaceId)
111
+ });
112
+ }
113
+ catch (error) {
114
+ return errorResult(error);
115
+ }
116
+ });
117
+ server.registerTool("gtm_list_variables", {
118
+ title: "List GTM Variables",
119
+ description: "List Google Tag Manager variables for a container. If workspaceId is omitted, variables are listed from all workspaces.",
120
+ inputSchema: {
121
+ accountId: z.string().min(1).describe("GTM account ID or full account path."),
122
+ containerId: z.string().min(1).describe("GTM container ID or full container path."),
123
+ workspaceId: z
124
+ .string()
125
+ .min(1)
126
+ .optional()
127
+ .describe("Optional GTM workspace ID or full workspace path.")
128
+ }
129
+ }, async ({ accountId, containerId, workspaceId }) => {
130
+ try {
131
+ return jsonResult({
132
+ workspaces: await gtm.listVariables(accountId, containerId, workspaceId)
133
+ });
134
+ }
135
+ catch (error) {
136
+ return errorResult(error);
137
+ }
138
+ });
139
+ if (!options.enableWriteTools) {
140
+ return;
141
+ }
142
+ server.registerTool("gtm_create_tag", {
143
+ title: "Create GTM Tag",
144
+ description: "Create a Google Tag Manager tag in a workspace. Requires GTM_ENABLE_WRITE_TOOLS=true and OAuth edit-containers scope.",
145
+ inputSchema: {
146
+ accountId: z.string().min(1).describe("GTM account ID or full account path."),
147
+ containerId: z.string().min(1).describe("Numeric GTM container ID or full container path."),
148
+ workspaceId: z.string().min(1).describe("GTM workspace ID or full workspace path."),
149
+ name: z.string().min(1).max(255).describe("Tag name."),
150
+ type: z
151
+ .string()
152
+ .min(1)
153
+ .default("googtag")
154
+ .describe("GTM tag type, for example googtag."),
155
+ parameter: z
156
+ .array(gtmParameterSchema)
157
+ .optional()
158
+ .describe("Raw GTM tag parameters accepted by the Tag Manager API."),
159
+ firingTriggerId: z
160
+ .array(z.string().min(1))
161
+ .min(1)
162
+ .describe("Trigger IDs that fire this tag."),
163
+ blockingTriggerId: z
164
+ .array(z.string().min(1))
165
+ .optional()
166
+ .describe("Optional trigger IDs that block this tag."),
167
+ tagFiringOption: z
168
+ .enum(["unlimited", "oncePerEvent", "oncePerLoad"])
169
+ .default("oncePerEvent")
170
+ .describe("Tag firing option.")
171
+ }
172
+ }, async (args) => {
173
+ try {
174
+ const tag = await gtm.createTag(args);
175
+ return jsonResult({ tag });
176
+ }
177
+ catch (error) {
178
+ return errorResult(error);
179
+ }
180
+ });
181
+ server.registerTool("gtm_update_tag", {
182
+ title: "Update GTM Tag",
183
+ description: "Update an existing Google Tag Manager tag in a workspace. Requires GTM_ENABLE_WRITE_TOOLS=true and OAuth edit-containers scope.",
184
+ inputSchema: {
185
+ accountId: z.string().min(1).describe("GTM account ID or full account path."),
186
+ containerId: z.string().min(1).describe("Numeric GTM container ID or full container path."),
187
+ workspaceId: z.string().min(1).describe("GTM workspace ID or full workspace path."),
188
+ tagId: z.string().min(1).describe("GTM tag ID or full tag path."),
189
+ name: z.string().min(1).max(255).optional().describe("Tag name."),
190
+ type: z.string().min(1).optional().describe("GTM tag type, for example googtag."),
191
+ parameter: z
192
+ .array(gtmParameterSchema)
193
+ .optional()
194
+ .describe("Raw GTM tag parameters accepted by the Tag Manager API."),
195
+ firingTriggerId: z
196
+ .array(z.string().min(1))
197
+ .optional()
198
+ .describe("Trigger IDs that fire this tag."),
199
+ blockingTriggerId: z
200
+ .array(z.string().min(1))
201
+ .optional()
202
+ .describe("Optional trigger IDs that block this tag."),
203
+ tagFiringOption: z
204
+ .enum(["unlimited", "oncePerEvent", "oncePerLoad"])
205
+ .optional()
206
+ .describe("Tag firing option."),
207
+ fingerprint: z
208
+ .string()
209
+ .min(1)
210
+ .optional()
211
+ .describe("Optional GTM fingerprint for optimistic concurrency.")
212
+ }
213
+ }, async (args) => {
214
+ try {
215
+ const tag = await gtm.updateTag(args);
216
+ return jsonResult({ tag });
217
+ }
218
+ catch (error) {
219
+ return errorResult(error);
220
+ }
221
+ });
222
+ server.registerTool("gtm_create_trigger", {
223
+ title: "Create GTM Trigger",
224
+ description: "Create a Google Tag Manager trigger in a workspace. Requires GTM_ENABLE_WRITE_TOOLS=true and OAuth edit-containers scope.",
225
+ inputSchema: {
226
+ accountId: z.string().min(1).describe("GTM account ID or full account path."),
227
+ containerId: z.string().min(1).describe("Numeric GTM container ID or full container path."),
228
+ workspaceId: z.string().min(1).describe("GTM workspace ID or full workspace path."),
229
+ name: z.string().min(1).max(255).describe("Trigger name."),
230
+ type: z.string().min(1).describe("GTM trigger type, for example click, pageview, or customEvent."),
231
+ filter: z.array(gtmConditionSchema).optional().describe("Optional trigger filters."),
232
+ autoEventFilter: z
233
+ .array(gtmConditionSchema)
234
+ .optional()
235
+ .describe("Optional auto-event trigger filters."),
236
+ customEventFilter: z
237
+ .array(gtmConditionSchema)
238
+ .optional()
239
+ .describe("Optional custom event trigger filters."),
240
+ parameter: z
241
+ .array(gtmParameterSchema)
242
+ .optional()
243
+ .describe("Raw GTM trigger parameters accepted by the Tag Manager API.")
244
+ }
245
+ }, async (args) => {
246
+ try {
247
+ const trigger = await gtm.createTrigger(args);
248
+ return jsonResult({ trigger });
249
+ }
250
+ catch (error) {
251
+ return errorResult(error);
252
+ }
253
+ });
254
+ server.registerTool("gtm_create_variable", {
255
+ title: "Create GTM Variable",
256
+ description: "Create a Google Tag Manager variable in a workspace. Requires GTM_ENABLE_WRITE_TOOLS=true and OAuth edit-containers scope.",
257
+ inputSchema: {
258
+ accountId: z.string().min(1).describe("GTM account ID or full account path."),
259
+ containerId: z.string().min(1).describe("Numeric GTM container ID or full container path."),
260
+ workspaceId: z.string().min(1).describe("GTM workspace ID or full workspace path."),
261
+ name: z.string().min(1).max(255).describe("Variable name."),
262
+ type: z.string().min(1).describe("GTM variable type, for example v, jsm, or c."),
263
+ parameter: z
264
+ .array(gtmParameterSchema)
265
+ .optional()
266
+ .describe("Raw GTM variable parameters accepted by the Tag Manager API."),
267
+ formatValue: z
268
+ .record(z.string(), z.unknown())
269
+ .optional()
270
+ .describe("Optional GTM variable formatValue.")
271
+ }
272
+ }, async (args) => {
273
+ try {
274
+ const variable = await gtm.createVariable(args);
275
+ return jsonResult({ variable });
276
+ }
277
+ catch (error) {
278
+ return errorResult(error);
279
+ }
280
+ });
281
+ }
@@ -0,0 +1,39 @@
1
+ export class AppError extends Error {
2
+ statusCode;
3
+ expose;
4
+ constructor(message, statusCode = 500, expose = false) {
5
+ super(message);
6
+ this.name = "AppError";
7
+ this.statusCode = statusCode;
8
+ this.expose = expose;
9
+ }
10
+ }
11
+ export function toSafeErrorMessage(error) {
12
+ if (error instanceof AppError && error.expose) {
13
+ return error.message;
14
+ }
15
+ const googleError = error;
16
+ const status = googleError.response?.status ?? googleError.code;
17
+ const statusText = googleError.response?.statusText;
18
+ const responseData = googleError.response?.data;
19
+ const responseError = responseData?.error;
20
+ const apiMessage = (typeof responseError === "object" ? responseError.message : responseError) ??
21
+ responseData?.error_description ??
22
+ googleError.errors?.map((item) => item.message).filter(Boolean).join("; ");
23
+ if (status) {
24
+ const prefix = `Google Tag Manager API request failed with status ${status}${statusText ? ` (${statusText})` : ""}.`;
25
+ return apiMessage ? `${prefix} ${apiMessage}` : prefix;
26
+ }
27
+ return "Request failed.";
28
+ }
29
+ export function logError(error) {
30
+ if (error instanceof Error) {
31
+ console.error({
32
+ name: error.name,
33
+ message: error.message,
34
+ stack: error.stack
35
+ });
36
+ return;
37
+ }
38
+ console.error({ error });
39
+ }
package/dist/stdio.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadConfig } from "./config/env.js";
4
+ import { createGoogleAuth } from "./google/auth.js";
5
+ import { GoogleTagManagerService } from "./gtm/gtm-service.js";
6
+ import { createGtmMcpServer } from "./mcp/server.js";
7
+ // NOTE: In stdio mode, stdout is the MCP protocol channel. Anything written to
8
+ // stdout will corrupt the JSON-RPC stream, so all diagnostics go to stderr.
9
+ async function main() {
10
+ const config = loadConfig();
11
+ const auth = createGoogleAuth(config);
12
+ const gtm = new GoogleTagManagerService(auth);
13
+ const server = createGtmMcpServer(gtm, config);
14
+ const transport = new StdioServerTransport();
15
+ await server.connect(transport);
16
+ console.error(`GTM MCP server (stdio) ready. Write tools: ${config.enableWriteTools ? "enabled" : "disabled"}.`);
17
+ }
18
+ main().catch((error) => {
19
+ console.error(error);
20
+ process.exit(1);
21
+ });
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "gtm-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "Secure MCP server for the Google Tag Manager API (read + create/update, no delete).",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "gtm-mcp-server": "dist/stdio.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "google-tag-manager",
19
+ "gtm",
20
+ "claude",
21
+ "claude-code"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/osama-humayun-spursol/gtm-mcp-server.git"
27
+ },
28
+ "homepage": "https://github.com/osama-humayun-spursol/gtm-mcp-server#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/osama-humayun-spursol/gtm-mcp-server/issues"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json",
37
+ "check": "tsc -p tsconfig.json --noEmit",
38
+ "dev": "tsx watch src/index.ts",
39
+ "start": "node dist/index.js",
40
+ "start:stdio": "node dist/stdio.js",
41
+ "prepare": "tsc -p tsconfig.json"
42
+ },
43
+ "engines": {
44
+ "node": ">=20.0.0"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.17.5",
48
+ "cors": "^2.8.5",
49
+ "dotenv": "^16.4.7",
50
+ "express": "^5.1.0",
51
+ "express-rate-limit": "^7.5.0",
52
+ "googleapis": "^171.4.0",
53
+ "helmet": "^8.0.0",
54
+ "zod": "^3.25.76"
55
+ },
56
+ "devDependencies": {
57
+ "@types/cors": "^2.8.17",
58
+ "@types/express": "^5.0.0",
59
+ "@types/node": "^22.10.2",
60
+ "tsx": "^4.19.2",
61
+ "typescript": "^5.7.2"
62
+ }
63
+ }