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 +21 -0
- package/README.md +323 -0
- package/dist/config/env.js +101 -0
- package/dist/google/auth.js +36 -0
- package/dist/google/oauth-setup.js +37 -0
- package/dist/gtm/gtm-service.js +157 -0
- package/dist/gtm/path.js +20 -0
- package/dist/http/app.js +111 -0
- package/dist/http/middleware.js +14 -0
- package/dist/index.js +23 -0
- package/dist/mcp/server.js +14 -0
- package/dist/mcp/tools.js +281 -0
- package/dist/shared/errors.js +39 -0
- package/dist/stdio.js +21 -0
- package/package.json +63 -0
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
|
+
}
|
package/dist/gtm/path.js
ADDED
|
@@ -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
|
+
}
|
package/dist/http/app.js
ADDED
|
@@ -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
|
+
}
|