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/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/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", 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"