Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions deploy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions google-analytics-sa/app.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
29 changes: 29 additions & 0 deletions google-analytics-sa/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
61 changes: 61 additions & 0 deletions google-analytics-sa/server/lib/sa-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
54 changes: 54 additions & 0 deletions google-analytics-sa/server/lib/sa-auth.ts
Original file line number Diff line number Diff line change
@@ -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<string> =>
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 },
});
34 changes: 34 additions & 0 deletions google-analytics-sa/server/main.ts
Original file line number Diff line number Diff line change
@@ -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<Env, typeof StateSchema>({
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));
Loading
Loading