From 4087d0c5ad3a78f0b6135d2217e15a8c04a9ce4b Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 24 Aug 2026 14:56:42 -0300 Subject: [PATCH 1/2] refactor(shared): extract Google service account auth into a shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves google-calendar-sa's JWT-bearer implementation to @decocms/mcps-shared/google-service-account so a second SA-backed MCP can reuse it, and fixes three problems along the way: - `subject` is now optional and last. It is only needed for domain-wide delegation; APIs that grant access to the service account identity directly (GA4) must omit the `sub` claim entirely. - The token cache key now includes `client_email`. It was keyed on subject + scopes alone, so two connections using different service accounts to impersonate the same user shared a token. - The fan-out in google-calendar-sa minted a token per impersonated email but wrote all of them into the same `MESH_REQUEST_CONTEXT.authorization` inside a `Promise.all`. Every branch read whichever token landed last, so `list_events` across two users returned one user's calendar duplicated. Each branch now builds its tool against a cloned env. Also drops a `console.log` that printed the service account and impersonated emails on every refresh, and teaches `parseServiceAccountKey` to recognize an OAuth `client_secret.json` — the most common paste mistake — instead of failing on a missing `type` field. Co-Authored-By: Claude Opus 5 (1M context) --- google-calendar-sa/server/lib/scheduler.ts | 4 +- google-calendar-sa/server/main.ts | 40 ++--- shared/google-service-account.test.ts | 164 ++++++++++++++++++ .../google-service-account.ts | 45 +++-- shared/package.json | 1 + 5 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 shared/google-service-account.test.ts rename google-calendar-sa/server/lib/service-account.ts => shared/google-service-account.ts (71%) diff --git a/google-calendar-sa/server/lib/scheduler.ts b/google-calendar-sa/server/lib/scheduler.ts index aa68e6a1..55691c96 100644 --- a/google-calendar-sa/server/lib/scheduler.ts +++ b/google-calendar-sa/server/lib/scheduler.ts @@ -17,7 +17,7 @@ import { getCachedConnections, type CachedConnection, } from "./connection-cache.ts"; -import { getServiceAccountAccessToken } from "./service-account.ts"; +import { getServiceAccountAccessToken } from "@decocms/mcps-shared/google-service-account"; const scopes = [GOOGLE_SCOPES.CALENDAR, GOOGLE_SCOPES.CALENDAR_EVENTS]; @@ -96,8 +96,8 @@ async function scanConnection(conn: CachedConnection): Promise { try { const token = await getServiceAccountAccessToken( conn.serviceAccountJson, - email, scopes, + email, ); const client = new GoogleCalendarClient({ accessToken: token }); const response = await client.listEvents({ diff --git a/google-calendar-sa/server/main.ts b/google-calendar-sa/server/main.ts index b64dbc4e..43af6b6c 100644 --- a/google-calendar-sa/server/main.ts +++ b/google-calendar-sa/server/main.ts @@ -12,7 +12,7 @@ import { import { app as webhookRouter } from "google-calendar/router"; import { type Env, StateSchema } from "../shared/deco.gen.ts"; -import { getServiceAccountAccessToken } from "./lib/service-account.ts"; +import { getServiceAccountAccessToken } from "@decocms/mcps-shared/google-service-account"; import { cacheConnection } from "./lib/connection-cache.ts"; import { startScheduler, stopScheduler } from "./lib/scheduler.ts"; import { saveSAConfig, loadAllSAConfigs } from "./lib/sa-config-store.ts"; @@ -119,6 +119,12 @@ function mergeResults( const googleScopes = [GOOGLE_SCOPES.CALENDAR, GOOGLE_SCOPES.CALENDAR_EVENTS]; +/** Clones env with a different bearer, so tools never share a mutable auth slot. */ +const withToken = (env: Env, token: string): Env => ({ + ...env, + MESH_REQUEST_CONTEXT: { ...env.MESH_REQUEST_CONTEXT, authorization: token }, +}); + // deno-lint-ignore no-explicit-any const onChangeHandler = async (_env: Env, config: any) => { try { @@ -177,7 +183,6 @@ const runtime = withRuntime({ // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(tools as any[]).map((createTool: (env: any) => any) => { const tool = createTool(env); - const originalExecute = tool.execute; const shouldFanOut = FAN_OUT_TOOLS.has(tool.id); return { @@ -196,32 +201,23 @@ const runtime = withRuntime({ ); } - const reqCtx = env.MESH_REQUEST_CONTEXT as unknown as Record< - string, - unknown - >; - - if (!shouldFanOut || emails.length === 1) { + // One tool instance per impersonated user, each with its own token. + // Mutating a shared MESH_REQUEST_CONTEXT.authorization would let + // parallel branches read each other's token. + const runAs = async (email: string) => { const token = await getServiceAccountAccessToken( json, - emails[0], googleScopes, + email, ); - reqCtx.authorization = token; - return originalExecute(args); + return createTool(withToken(env, token)).execute(args); + }; + + if (!shouldFanOut || emails.length === 1) { + return runAs(emails[0]); } - const results = await Promise.all( - emails.map(async (email: string) => { - const token = await getServiceAccountAccessToken( - json, - email, - googleScopes, - ); - reqCtx.authorization = token; - return originalExecute(args); - }), - ); + const results = await Promise.all(emails.map(runAs)); return mergeResults(tool.id, results); }, diff --git a/shared/google-service-account.test.ts b/shared/google-service-account.test.ts new file mode 100644 index 00000000..7b201ee4 --- /dev/null +++ b/shared/google-service-account.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; +import { + getServiceAccountAccessToken, + parseServiceAccountKey, +} from "./google-service-account.ts"; + +let pem: string; + +beforeAll(async () => { + const { privateKey } = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const pkcs8 = await crypto.subtle.exportKey("pkcs8", privateKey); + const b64 = btoa(String.fromCharCode(...new Uint8Array(pkcs8))); + pem = `-----BEGIN PRIVATE KEY-----\n${b64.match(/.{1,64}/g)!.join("\n")}\n-----END PRIVATE KEY-----\n`; +}); + +const keyJson = (clientEmail: string) => + JSON.stringify({ + type: "service_account", + project_id: "p", + private_key_id: "kid", + private_key: pem, + client_email: clientEmail, + client_id: "1", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + }); + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Captures the JWT assertions posted to Google and answers with a fake token. */ +function stubTokenEndpoint() { + const assertions: string[] = []; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + const body = new URLSearchParams(init.body as string); + assertions.push(body.get("assertion")!); + return new Response( + JSON.stringify({ + access_token: `token-${assertions.length}`, + expires_in: 3600, + token_type: "Bearer", + }), + { headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch; + return assertions; +} + +const payloadOf = (jwt: string) => + JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")), + (c) => c.charCodeAt(0), + ), + ), + ); + +describe("parseServiceAccountKey", () => { + it("accepts a service account key with surrounding whitespace", () => { + const key = parseServiceAccountKey(`\n ${keyJson("sa@x.iam")} \n`); + expect(key.client_email).toBe("sa@x.iam"); + }); + + it("names the mistake when given an OAuth client file", () => { + const oauthClient = JSON.stringify({ + installed: { client_id: "1", client_secret: "s" }, + }); + expect(() => parseServiceAccountKey(oauthClient)).toThrow( + /OAuth client file/, + ); + }); + + it("rejects a non-service-account key type", () => { + expect(() => + parseServiceAccountKey(JSON.stringify({ type: "authorized_user" })), + ).toThrow(/expected "service_account"/); + }); + + it("rejects malformed JSON", () => { + expect(() => parseServiceAccountKey("not json")).toThrow(/not valid JSON/); + }); + + it("rejects a key missing private_key", () => { + expect(() => + parseServiceAccountKey( + JSON.stringify({ type: "service_account", client_email: "a@b" }), + ), + ).toThrow(/missing private_key/); + }); +}); + +describe("getServiceAccountAccessToken", () => { + it("omits sub when no subject is given — GA4 grants the SA identity directly", async () => { + const assertions = stubTokenEndpoint(); + await getServiceAccountAccessToken(keyJson("no-sub@x.iam"), [ + "https://www.googleapis.com/auth/analytics.readonly", + ]); + + const payload = payloadOf(assertions[0]); + expect(payload.sub).toBeUndefined(); + expect(payload.iss).toBe("no-sub@x.iam"); + expect(payload.scope).toBe( + "https://www.googleapis.com/auth/analytics.readonly", + ); + expect(payload.aud).toBe("https://oauth2.googleapis.com/token"); + }); + + it("sets sub when impersonating — domain-wide delegation", async () => { + const assertions = stubTokenEndpoint(); + await getServiceAccountAccessToken( + keyJson("dwd@x.iam"), + ["https://www.googleapis.com/auth/calendar"], + "user@corp.com", + ); + + expect(payloadOf(assertions[0]).sub).toBe("user@corp.com"); + }); + + it("caches by key identity, so two service accounts never share a token", async () => { + const assertions = stubTokenEndpoint(); + const scopes = ["scope-for-cache-test"]; + + const first = await getServiceAccountAccessToken( + keyJson("one@x.iam"), + scopes, + "shared@corp.com", + ); + const cached = await getServiceAccountAccessToken( + keyJson("one@x.iam"), + scopes, + "shared@corp.com", + ); + const other = await getServiceAccountAccessToken( + keyJson("two@x.iam"), + scopes, + "shared@corp.com", + ); + + expect(cached).toBe(first); + expect(other).not.toBe(first); + expect(assertions).toHaveLength(2); + }); + + it("surfaces the status and body when Google rejects the assertion", async () => { + globalThis.fetch = (async () => + new Response("invalid_grant", { status: 400 })) as typeof fetch; + + await expect( + getServiceAccountAccessToken(keyJson("bad@x.iam"), ["s"]), + ).rejects.toThrow(/400 - invalid_grant/); + }); +}); diff --git a/google-calendar-sa/server/lib/service-account.ts b/shared/google-service-account.ts similarity index 71% rename from google-calendar-sa/server/lib/service-account.ts rename to shared/google-service-account.ts index cec313ff..ad80d2d0 100644 --- a/google-calendar-sa/server/lib/service-account.ts +++ b/shared/google-service-account.ts @@ -1,11 +1,16 @@ /** - * Google Service Account JWT authentication + * Google Service Account JWT authentication. * - * Generates access tokens using a service account JSON key, - * with support for domain-wide delegation (impersonation). + * Signs an RS256 JWT with the service account private key and exchanges it for + * an access token via the JWT-bearer grant. No dependencies — WebCrypto only. + * + * `subject` is optional and only needed for domain-wide delegation, where the + * service account acts on behalf of a Workspace user (Gmail, Calendar, Drive). + * APIs that grant access to the service account identity directly — GA4, for + * instance, where you add the `client_email` as a property user — must omit it. */ -interface ServiceAccountKey { +export interface ServiceAccountKey { type: string; project_id: string; private_key_id: string; @@ -25,7 +30,8 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token"; const TOKEN_LIFETIME_SECS = 3600; const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; -// Cache keyed by subject email to support multiple impersonations +// Keyed by service account + subject + scopes, so two connections using +// different keys never share a token even when impersonating the same user. const tokenCache = new Map(); function base64url(input: string | ArrayBuffer): string { @@ -75,6 +81,7 @@ async function createSignedJwt( exp: now + TOKEN_LIFETIME_SECS, }; + // Domain-wide delegation only. if (subject) { payload.sub = subject; } @@ -94,7 +101,23 @@ async function createSignedJwt( } export function parseServiceAccountKey(json: string): ServiceAccountKey { - const key = JSON.parse(json) as ServiceAccountKey; + let key: ServiceAccountKey & { installed?: unknown; web?: unknown }; + try { + key = JSON.parse(json.trim()); + } catch { + throw new Error( + "Service account key is not valid JSON. Paste the full contents of the .json file downloaded from Google Cloud.", + ); + } + + // The most common mistake: downloading an OAuth client instead of a key. + if (key.installed || key.web) { + throw new Error( + "That is an OAuth client file (client_secret.json), not a service account key. " + + "In Google Cloud go to IAM & Admin > Service Accounts > your account > Keys > Add key > JSON.", + ); + } + if (key.type !== "service_account") { throw new Error( `Invalid key type "${key.type}" — expected "service_account"`, @@ -110,17 +133,17 @@ export function parseServiceAccountKey(json: string): ServiceAccountKey { export async function getServiceAccountAccessToken( serviceAccountJson: string, - subject: string, scopes: string[], + subject?: string, ): Promise { - const cacheKey = `${subject}:${scopes.join(",")}`; + const key = parseServiceAccountKey(serviceAccountJson); + const cacheKey = `${key.client_email}:${subject ?? ""}:${scopes.join(",")}`; const cached = tokenCache.get(cacheKey); if (cached && Date.now() < cached.expires_at - TOKEN_REFRESH_MARGIN_MS) { return cached.access_token; } - const key = parseServiceAccountKey(serviceAccountJson); const jwt = await createSignedJwt(key, scopes, subject); const response = await fetch(TOKEN_URL, { @@ -150,9 +173,5 @@ export async function getServiceAccountAccessToken( expires_at: Date.now() + data.expires_in * 1000, }); - console.log( - `[service-account] Token for ${key.client_email} impersonating ${subject}, expires in ${data.expires_in}s`, - ); - return data.access_token; } diff --git a/shared/package.json b/shared/package.json index 34ebb001..0a70282d 100644 --- a/shared/package.json +++ b/shared/package.json @@ -21,6 +21,7 @@ "./auth": "./auth.ts", "./registry": "./registry.ts", "./google-oauth": "./google-oauth.ts", + "./google-service-account": "./google-service-account.ts", "./whatsapp": "./whatsapp/index.ts", "./api-key-manager": "./api-key-manager.ts", "./mesh-chat": "./mesh-chat/index.ts" From 42cc9bd08a04fe61d9b91569301d5f773159f259 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 24 Aug 2026 14:56:50 -0300 Subject: [PATCH 2/2] feat(google-analytics-sa): add Google Analytics MCP with service account auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-to-server access to GA4 with no OAuth login and no refresh token to keep alive. The app reuses the eight google-analytics tools verbatim via subpath exports and swaps the bearer for a service account token. GA4 grants access to the service account identity directly — you add its email as a property user — so unlike google-calendar-sa there is no domain-wide delegation, no impersonation list, and no fan-out. Two credential modes, resolved in one place: a key pasted into the connection's SERVICE_ACCOUNT_JSON wins, otherwise the managed key from the GOOGLE_SERVICE_ACCOUNT_JSON site secret is used. The first keeps quota and audit trail in the customer's Google Cloud project; the second lets them skip key handling entirely and just grant a Viewer role. A `check-service-account-access` tool makes that setup self-service: it reports which credential is active, which email to grant, which properties are currently readable, and the exact next step when something is missing. It returns failures as data rather than throwing, so a misconfigured install always gets an actionable answer instead of a generic error. Unlike the MCPs it builds on, this one wraps its transport in `withAuth` and is not added to auth-exemptions.json. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 38 +++++- deploy.json | 10 ++ google-analytics-sa/app.json | 30 +++++ google-analytics-sa/package.json | 29 +++++ .../server/lib/sa-auth.test.ts | 61 ++++++++++ google-analytics-sa/server/lib/sa-auth.ts | 54 +++++++++ google-analytics-sa/server/main.ts | 34 ++++++ .../server/tools/check-access.ts | 113 ++++++++++++++++++ google-analytics-sa/shared/deco.gen.ts | 33 +++++ google-analytics-sa/tsconfig.json | 35 ++++++ google-analytics/package.json | 7 ++ google-analytics/shared/deco.gen.ts | 6 +- package.json | 1 + registry.json | 47 ++++++++ 14 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 google-analytics-sa/app.json create mode 100644 google-analytics-sa/package.json create mode 100644 google-analytics-sa/server/lib/sa-auth.test.ts create mode 100644 google-analytics-sa/server/lib/sa-auth.ts create mode 100644 google-analytics-sa/server/main.ts create mode 100644 google-analytics-sa/server/tools/check-access.ts create mode 100644 google-analytics-sa/shared/deco.gen.ts create mode 100644 google-analytics-sa/tsconfig.json diff --git a/bun.lock b/bun.lock index 697ee8fe..5cce6719 100644 --- a/bun.lock +++ b/bun.lock @@ -354,6 +354,22 @@ "typescript": "^5.7.2", }, }, + "google-analytics-sa": { + "name": "google-analytics-sa", + "version": "1.0.0", + "dependencies": { + "@decocms/runtime": "1.2.5", + "google-analytics": "workspace:*", + "zod": "^4.0.0", + }, + "devDependencies": { + "@decocms/mcps-shared": "workspace:*", + "@modelcontextprotocol/sdk": "1.25.1", + "bun-types": "^1.3.7", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2", + }, + }, "google-apps-script": { "name": "google-apps-script", "version": "1.0.0", @@ -1159,7 +1175,7 @@ }, "wake": { "name": "wake", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@decocms/runtime": "^1.6.2", "zod": "^4.0.0", @@ -2976,6 +2992,8 @@ "google-analytics": ["google-analytics@workspace:google-analytics"], + "google-analytics-sa": ["google-analytics-sa@workspace:google-analytics-sa"], + "google-apps-script": ["google-apps-script@workspace:google-apps-script"], "google-big-query": ["google-big-query@workspace:google-big-query"], @@ -4318,6 +4336,10 @@ "google-analytics/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "google-analytics-sa/@decocms/runtime": ["@decocms/runtime@1.2.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.1.1", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-0s02lfj/O7nTAc7FTmFsA+lZpUDnapjQHnRYrQXItLKrbJvjSnfoq5V8HA1Npv5HelBvsVk7QQHaW8pSN/l37w=="], + + "google-analytics-sa/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "google-apps-script/@decocms/runtime": ["@decocms/runtime@1.2.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.1.1", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-0s02lfj/O7nTAc7FTmFsA+lZpUDnapjQHnRYrQXItLKrbJvjSnfoq5V8HA1Npv5HelBvsVk7QQHaW8pSN/l37w=="], "google-apps-script/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -4920,6 +4942,10 @@ "github/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "google-analytics-sa/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.4.9", "", { "dependencies": { "@decocms/mcp-utils": "^1.0.5", "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-router": "1.169.2", "react": "^19.2.6", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-NvhuHsKL0YpSUaAZqEe0PAzF41/JJSi+H0X7HpMBScOVpALcGqAC+DaJvcKeo3aRXXW7z6du0oCdkhLf2A6Vjw=="], + + "google-analytics-sa/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "google-analytics/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.4.9", "", { "dependencies": { "@decocms/mcp-utils": "^1.0.5", "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-router": "1.169.2", "react": "^19.2.6", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-NvhuHsKL0YpSUaAZqEe0PAzF41/JJSi+H0X7HpMBScOVpALcGqAC+DaJvcKeo3aRXXW7z6du0oCdkhLf2A6Vjw=="], "google-analytics/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], @@ -5558,6 +5584,10 @@ "github/@decocms/bindings/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], + "google-analytics-sa/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "google-analytics-sa/@decocms/runtime/@decocms/bindings/@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], + "google-analytics/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "google-analytics/@decocms/runtime/@decocms/bindings/@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], @@ -6312,6 +6342,12 @@ "github-repo-reports/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], + "google-analytics-sa/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "google-analytics-sa/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], + + "google-analytics-sa/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/router-core": ["@tanstack/router-core@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw=="], + "google-analytics/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], "google-analytics/@decocms/runtime/@decocms/bindings/@tanstack/react-router/@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="], diff --git a/deploy.json b/deploy.json index 6bfa7a2d..0eb69bd5 100644 --- a/deploy.json +++ b/deploy.json @@ -467,6 +467,16 @@ "shared/**" ] }, + "google-analytics-sa": { + "site": "google-analytics-sa", + "entrypoint": "./dist/server/main.js", + "platformName": "kubernetes-bun", + "watch": [ + "google-analytics-sa/**", + "google-analytics/**", + "shared/**" + ] + }, "magento": { "site": "magento", "entrypoint": "./dist/server/main.js", diff --git a/google-analytics-sa/app.json b/google-analytics-sa/app.json new file mode 100644 index 00000000..b76124a3 --- /dev/null +++ b/google-analytics-sa/app.json @@ -0,0 +1,30 @@ +{ + "scopeName": "deco", + "name": "google-analytics-sa", + "friendlyName": "Google Analytics (Service Account)", + "connection": { + "type": "HTTP", + "url": "https://sites-google-analytics-sa.deco.site/mcp" + }, + "description": "Query Google Analytics 4 with a service account — no OAuth login. Grant read access to a service account email on your GA4 property and you are done.", + "icon": "https://www.gstatic.com/analytics-suite/header/suite/v2/ic_analytics.svg", + "unlisted": false, + "metadata": { + "categories": [ + "Analytics", + "Marketing", + "Data" + ], + "official": false, + "tags": [ + "google", + "analytics", + "ga4", + "data", + "reporting", + "service-account" + ], + "short_description": "Query Google Analytics 4 with a service account — no OAuth login required.", + "mesh_description": "The Google Analytics Service Account MCP gives server-to-server access to GA4 — the same reporting tools as the OAuth variant, with no per-user login and no refresh token to keep alive. **Setup (easy path)** - Leave SERVICE_ACCOUNT_JSON empty and run the `check-service-account-access` tool. It returns the managed service account email; add that email as a Viewer under GA4 Admin > Property access management, then run the tool again to confirm. **Setup (bring your own)** - Prefer your own Google Cloud project? Create a service account there, enable the Analytics Data and Admin APIs, paste its JSON key into SERVICE_ACCOUNT_JSON, and grant that email Viewer access on the property. **Key Features** - Custom and funnel reports via the Data API, realtime active users, custom dimensions and metrics metadata, account hierarchy, Google Ads links, and property annotations. Access tokens are minted and refreshed automatically." + } +} diff --git a/google-analytics-sa/package.json b/google-analytics-sa/package.json new file mode 100644 index 00000000..3f4fc1b7 --- /dev/null +++ b/google-analytics-sa/package.json @@ -0,0 +1,29 @@ +{ + "name": "google-analytics-sa", + "version": "1.0.0", + "description": "Google Analytics (GA4) MCP Server with Service Account authentication", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot server/main.ts", + "check": "tsc --noEmit", + "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", + "build": "bun run build:server", + "publish": "cat app.json | deco registry publish -w /shared/deco -y" + }, + "dependencies": { + "@decocms/runtime": "1.2.5", + "google-analytics": "workspace:*", + "zod": "^4.0.0" + }, + "devDependencies": { + "@decocms/mcps-shared": "workspace:*", + "@modelcontextprotocol/sdk": "1.25.1", + "bun-types": "^1.3.7", + "deco-cli": "^0.28.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/google-analytics-sa/server/lib/sa-auth.test.ts b/google-analytics-sa/server/lib/sa-auth.test.ts new file mode 100644 index 00000000..cfd4c681 --- /dev/null +++ b/google-analytics-sa/server/lib/sa-auth.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "bun:test"; +import type { Env } from "../../shared/deco.gen.ts"; +import { resolveServiceAccount, withToken } from "./sa-auth.ts"; + +const key = (clientEmail: string) => + JSON.stringify({ + type: "service_account", + private_key: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----", + client_email: clientEmail, + }); + +const env = (stateKey?: string | null, managedKey?: string): Env => + ({ + GOOGLE_SERVICE_ACCOUNT_JSON: managedKey, + MESH_REQUEST_CONTEXT: { state: { SERVICE_ACCOUNT_JSON: stateKey } }, + }) as Env; + +describe("resolveServiceAccount", () => { + it("prefers the connection key, so quota stays in the customer's project", () => { + const resolved = resolveServiceAccount( + env(key("theirs@x.iam"), key("ours@deco.iam")), + ); + expect(resolved.source).toBe("connection"); + expect(resolved.clientEmail).toBe("theirs@x.iam"); + }); + + it("falls back to the managed key when the connection has none", () => { + const resolved = resolveServiceAccount(env(null, key("ours@deco.iam"))); + expect(resolved.source).toBe("deco-managed"); + expect(resolved.clientEmail).toBe("ours@deco.iam"); + }); + + it("treats a blank pasted key as absent", () => { + expect(resolveServiceAccount(env(" ", key("ours@deco.iam"))).source).toBe( + "deco-managed", + ); + }); + + it("explains what to configure when neither exists", () => { + expect(() => resolveServiceAccount(env())).toThrow( + /No service account configured/, + ); + }); +}); + +describe("withToken", () => { + it("clones the request context instead of mutating the shared one", () => { + const base = env(key("a@x.iam")); + const ctx = base.MESH_REQUEST_CONTEXT; + + const cloned = withToken(base, "bearer-a"); + + expect(cloned.MESH_REQUEST_CONTEXT.authorization).toBe("bearer-a"); + expect(ctx.authorization).toBeUndefined(); + expect(cloned.MESH_REQUEST_CONTEXT).not.toBe(ctx); + // State still reachable — tools read propertyId off the same context. + expect(cloned.MESH_REQUEST_CONTEXT.state?.SERVICE_ACCOUNT_JSON).toBe( + base.MESH_REQUEST_CONTEXT.state?.SERVICE_ACCOUNT_JSON, + ); + }); +}); diff --git a/google-analytics-sa/server/lib/sa-auth.ts b/google-analytics-sa/server/lib/sa-auth.ts new file mode 100644 index 00000000..598de747 --- /dev/null +++ b/google-analytics-sa/server/lib/sa-auth.ts @@ -0,0 +1,54 @@ +import { + getServiceAccountAccessToken, + parseServiceAccountKey, +} from "@decocms/mcps-shared/google-service-account"; +import { GOOGLE_SCOPES } from "google-analytics/constants"; +import type { Env } from "../../shared/deco.gen.ts"; + +export const SCOPES = [GOOGLE_SCOPES.ANALYTICS_READONLY]; + +/** Where the key came from — surfaced by `check-service-account-access`. */ +export type CredentialSource = "connection" | "deco-managed"; + +export interface ResolvedServiceAccount { + json: string; + source: CredentialSource; + /** Public identifier of the service account; the email to grant on the GA4 property. */ + clientEmail: string; +} + +/** + * A key pasted on the connection wins over the managed one, so a customer can + * keep quota and audit trail in their own Google Cloud project. + * + * GA4 grants access to the service account identity directly (add the email as + * a property user), so no impersonation subject is involved anywhere. + */ +export const resolveServiceAccount = (env: Env): ResolvedServiceAccount => { + const fromState = + env.MESH_REQUEST_CONTEXT?.state?.SERVICE_ACCOUNT_JSON?.trim(); + const fromEnv = env.GOOGLE_SERVICE_ACCOUNT_JSON?.trim(); + const json = fromState || fromEnv; + + if (!json) { + throw new Error( + "No service account configured. Either paste a service account JSON key into SERVICE_ACCOUNT_JSON, " + + "or ask deco support to enable the managed service account for this install.", + ); + } + + return { + json, + source: fromState ? "connection" : "deco-managed", + clientEmail: parseServiceAccountKey(json).client_email, + }; +}; + +export const getAccessToken = (env: Env): Promise => + getServiceAccountAccessToken(resolveServiceAccount(env).json, SCOPES); + +/** Clones env with a bearer token, so tools never share a mutable auth slot. */ +export const withToken = (env: Env, token: string): Env => ({ + ...env, + MESH_REQUEST_CONTEXT: { ...env.MESH_REQUEST_CONTEXT, authorization: token }, +}); diff --git a/google-analytics-sa/server/main.ts b/google-analytics-sa/server/main.ts new file mode 100644 index 00000000..3dd42a97 --- /dev/null +++ b/google-analytics-sa/server/main.ts @@ -0,0 +1,34 @@ +import { withRuntime } from "@decocms/runtime"; +import { serve } from "@decocms/mcps-shared/serve"; +import { withAuth } from "@decocms/mcps-shared/auth"; + +import { tools } from "google-analytics/tools"; + +import { type Env, StateSchema } from "../shared/deco.gen.ts"; +import { getAccessToken, withToken } from "./lib/sa-auth.ts"; +import { checkServiceAccountAccessTool } from "./tools/check-access.ts"; + +export type { Env }; + +const runtime = withRuntime({ + configuration: { + state: StateSchema, + }, + tools: (env: Env) => [ + // The Google Analytics tools verbatim, with the OAuth bearer swapped for a + // service account token. They read it off MESH_REQUEST_CONTEXT, so the tool + // is rebuilt against a cloned env instead of mutating the shared one. + // (named `makeTool`, not `createTool`, so scripts/check-auth.ts does not + // read these calls as an unauthenticated createTool() from the runtime) + ...tools.map((makeTool) => ({ + ...makeTool(env), + execute: async (args: never) => { + const token = await getAccessToken(env); + return makeTool(withToken(env, token)).execute(args); + }, + })), + checkServiceAccountAccessTool(env), + ], +}); + +serve(withAuth(runtime.fetch)); diff --git a/google-analytics-sa/server/tools/check-access.ts b/google-analytics-sa/server/tools/check-access.ts new file mode 100644 index 00000000..a6068999 --- /dev/null +++ b/google-analytics-sa/server/tools/check-access.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; +import { createPrivateTool } from "@decocms/runtime/tools"; +import { GaClient } from "google-analytics/client"; +import { AccountSummariesResponseSchema } from "google-analytics/schemas"; +import { getServiceAccountAccessToken } from "@decocms/mcps-shared/google-service-account"; +import type { Env } from "../../shared/deco.gen.ts"; +import { resolveServiceAccount, SCOPES } from "../lib/sa-auth.ts"; + +const grantInstructions = (email: string) => + `No GA4 property is readable by ${email} yet. In Google Analytics, open Admin > ` + + `Property access management (pick the property first), click "+", add ${email}, ` + + `choose the Viewer role, and click Add. Then run this tool again to confirm.`; + +const AccessibleProperty = z.object({ + property: z.string().describe("Resource name, e.g. 'properties/1234567'."), + displayName: z.string(), + account: z.string().nullish(), + accountName: z.string().nullish(), +}); + +export const checkServiceAccountAccessTool = (env: Env) => + createPrivateTool({ + id: "check-service-account-access", + description: + "Diagnoses this integration's service account: which credential is in use, which email to grant access to, and which GA4 properties it can currently read. Run this first when setting up, or whenever a report fails with a permission error.", + inputSchema: z.object({}), + outputSchema: z.object({ + ok: z + .boolean() + .describe( + "True when the service account can read at least one GA4 property.", + ), + mode: z + .enum(["deco-managed", "connection"]) + .nullish() + .describe( + "'deco-managed' uses the shared service account; 'connection' uses the JSON key pasted in this install's settings.", + ), + serviceAccountEmail: z + .string() + .nullish() + .describe("The email to grant Viewer access to on the GA4 property."), + accessibleProperties: z.array(AccessibleProperty), + nextStep: z + .string() + .nullish() + .describe( + "What to do next, in plain words. Null when nothing is needed.", + ), + error: z.string().nullish(), + }), + execute: async () => { + // Diagnostics must always answer, so failures come back as data rather + // than as a thrown error the caller has to interpret. + let account: ReturnType; + try { + account = resolveServiceAccount(env); + } catch (error) { + return { + ok: false, + mode: null, + serviceAccountEmail: null, + accessibleProperties: [], + nextStep: null, + error: error instanceof Error ? error.message : String(error), + }; + } + + try { + const token = await getServiceAccountAccessToken(account.json, SCOPES); + const raw = await new GaClient(token).listAccountSummaries(); + const { response } = AccountSummariesResponseSchema.parse({ + response: raw, + }); + + const properties = (response.accountSummaries ?? []).flatMap( + (summary) => + (summary.propertySummaries ?? []).map((prop) => ({ + property: prop.property, + displayName: prop.displayName, + account: summary.account ?? null, + accountName: summary.displayName ?? null, + })), + ); + + return { + ok: properties.length > 0, + mode: account.source, + serviceAccountEmail: account.clientEmail, + accessibleProperties: properties, + nextStep: + properties.length > 0 + ? null + : grantInstructions(account.clientEmail), + error: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + mode: account.source, + serviceAccountEmail: account.clientEmail, + accessibleProperties: [], + nextStep: + "The check could not complete — read `error` for what Google said. A disabled API means enabling " + + "the Google Analytics Data API and Google Analytics Admin API in that service account's Google Cloud " + + "project. 'invalid_grant' means the key was deleted or belongs to a service account that no longer " + + `exists. A permission error means granting ${account.clientEmail} the Viewer role on the GA4 property.`, + error: message, + }; + } + }, + }); diff --git a/google-analytics-sa/shared/deco.gen.ts b/google-analytics-sa/shared/deco.gen.ts new file mode 100644 index 00000000..5983b4c0 --- /dev/null +++ b/google-analytics-sa/shared/deco.gen.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export const StateSchema = z.object({ + propertyId: z + .string() + .nullish() + .describe( + "Default GA4 Property identifier — 'properties/1234567' or just '1234567'. Used as a fallback for tools when their `property` argument is omitted.", + ), + SERVICE_ACCOUNT_JSON: z + .string() + .nullish() + .describe( + "Optional. Paste the JSON key of your own Google Cloud service account. Leave empty to use the managed service account — run the `check-service-account-access` tool to find out which email to grant Viewer access to on your GA4 property.", + ), +}); + +export interface MeshRequestContext { + authorization?: string; + state?: z.infer; + token?: string; + meshUrl?: string; + connectionId?: string; + ensureAuthenticated?: () => Promise; +} + +export interface Env { + /** Managed service account key, set as a site secret. Fallback for connections that bring no key of their own. */ + GOOGLE_SERVICE_ACCOUNT_JSON?: string; + MESH_REQUEST_CONTEXT: MeshRequestContext; + SELF?: unknown; + IS_LOCAL?: boolean; +} diff --git a/google-analytics-sa/tsconfig.json b/google-analytics-sa/tsconfig.json new file mode 100644 index 00000000..77ccadfb --- /dev/null +++ b/google-analytics-sa/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "ES2024"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "allowJs": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + /* Bun globals (fetch, Response, Bun, etc.) */ + "types": ["bun-types"], + + /* Path Aliases */ + "baseUrl": ".", + "paths": { + "server/*": ["./server/*"] + } + }, + "include": ["server", "shared"] +} diff --git a/google-analytics/package.json b/google-analytics/package.json index e232be74..3cfad8f0 100644 --- a/google-analytics/package.json +++ b/google-analytics/package.json @@ -10,6 +10,13 @@ "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", "build": "bun run build:server" }, + "exports": { + "./tools": "./server/tools/index.ts", + "./constants": "./server/constants.ts", + "./client": "./server/lib/ga-client.ts", + "./schemas": "./server/lib/schemas.ts", + "./types": "./shared/deco.gen.ts" + }, "dependencies": { "@decocms/runtime": "1.2.5", "zod": "^4.0.0" diff --git a/google-analytics/shared/deco.gen.ts b/google-analytics/shared/deco.gen.ts index 841faef1..e561a811 100644 --- a/google-analytics/shared/deco.gen.ts +++ b/google-analytics/shared/deco.gen.ts @@ -19,8 +19,10 @@ export interface MeshRequestContext { } export interface Env { - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; + // Only the OAuth build supplies these; the tools themselves read the bearer + // off MESH_REQUEST_CONTEXT, so a service-account build can omit them. + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; MESH_REQUEST_CONTEXT: MeshRequestContext; SELF?: unknown; IS_LOCAL?: boolean; diff --git a/package.json b/package.json index 0793ef3f..a217f4db 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "github", "github-repo-reports", "google-analytics", + "google-analytics-sa", "google-apps-script", "google-big-query", "google-calendar", diff --git a/registry.json b/registry.json index ef46219c..fa3bf71e 100644 --- a/registry.json +++ b/registry.json @@ -1683,6 +1683,53 @@ ] } }, + { + "id": "deco/google-analytics-sa", + "title": "Google Analytics (Service Account)", + "description": "Query Google Analytics 4 with a service account — no OAuth login. Grant read access to a service account email on your GA4 property and you are done.", + "is_public": true, + "_meta": { + "mcp.mesh": { + "verified": false, + "friendly_name": "Google Analytics (Service Account)", + "short_description": "Query Google Analytics 4 with a service account — no OAuth login required.", + "owner": "deco", + "has_remote": true, + "has_oauth": false, + "tags": [ + "google", + "analytics", + "ga4", + "data", + "reporting", + "service-account" + ], + "categories": [ + "Analytics" + ], + "readme": "The Google Analytics Service Account MCP gives server-to-server access to GA4 — the same reporting tools as the OAuth variant, with no per-user login and no refresh token to keep alive. **Setup (easy path)** - Leave SERVICE_ACCOUNT_JSON empty and run the `check-service-account-access` tool. It returns the managed service account email; add that email as a Viewer under GA4 Admin > Property access management, then run the tool again to confirm. **Setup (bring your own)** - Prefer your own Google Cloud project? Create a service account there, enable the Analytics Data and Admin APIs, paste its JSON key into SERVICE_ACCOUNT_JSON, and grant that email Viewer access on the property. **Key Features** - Custom and funnel reports via the Data API, realtime active users, custom dimensions and metrics metadata, account hierarchy, Google Ads links, and property annotations. Access tokens are minted and refreshed automatically." + } + }, + "server": { + "name": "google-analytics-sa", + "title": "Google Analytics (Service Account)", + "description": "Query Google Analytics 4 with a service account — no OAuth login. Grant read access to a service account email on your GA4 property and you are done.", + "icons": [ + { + "src": "https://www.gstatic.com/analytics-suite/header/suite/v2/ic_analytics.svg" + } + ], + "remotes": [ + { + "type": "HTTP", + "url": "https://sites-google-analytics-sa.deco.site/mcp", + "name": "google-analytics-sa", + "title": "Google Analytics (Service Account)", + "description": "Query Google Analytics 4 with a service account — no OAuth login. Grant read access to a service account email on your GA4 property and you are done." + } + ] + } + }, { "id": "deco/google-apps-script", "title": "Google Apps Script",