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
4 changes: 3 additions & 1 deletion google-analytics/server/lib/ga-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const GA4_DATA_API = "https://analyticsdata.googleapis.com/v1beta";
const GA4_ADMIN_API = "https://analyticsadmin.googleapis.com/v1beta";
// Property annotations are v1alpha-only (not present in v1beta).
const GA4_ADMIN_ALPHA_API = "https://analyticsadmin.googleapis.com/v1alpha";
// runFunnelReport only exists on the Data API's v1alpha; the v1beta path 404s.
const GA4_DATA_ALPHA_API = "https://analyticsdata.googleapis.com/v1alpha";

// Hard limit on pagination loops to prevent runaway requests if GA4 returns a
// repeated nextPageToken during a quota event or transient backend failure.
Expand Down Expand Up @@ -107,7 +109,7 @@ export class GaClient {

async runFunnelReport(property: string, body: object): Promise<unknown> {
const prop = normalizeProperty(property);
return this.request(`${GA4_DATA_API}/${prop}:runFunnelReport`, {
return this.request(`${GA4_DATA_ALPHA_API}/${prop}:runFunnelReport`, {
method: "POST",
body: JSON.stringify(body),
});
Expand Down
10 changes: 10 additions & 0 deletions google-workspace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ mesh client ──► google-workspace MCP ──► calendarmcp.googleapis.com/

The OAuth flow is the standard `createGoogleOAuth` from `@decocms/mcps-shared` — PKCE with refresh-token rotation. The full union of scopes is sent at consent time, so a single approval covers every service.

### The OAuth client must be marked "agentic"

From **2026-11-11** Google's Workspace MCP servers reject OAuth clients that are not marked as agentic (`is_agentic`, Google Cloud Console → Credentials → the OAuth client). From **2026-11-12** the setting is permanent: changing it means a new client ID, and every user reconnects.

- The client in use is `GOOGLE_WORKSPACE_CLIENT_ID` / `GOOGLE_WORKSPACE_CLIENT_SECRET` when set, else the shared `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`.
- Google recommends a separate client when one client serves both agent and non-agent traffic. All MCPs in this repo are agent traffic, so marking the shared client is enough unless it is also used elsewhere (e.g. a human sign-in).
- If the client is not marked, tool calls fail with an explicit "not marked as agentic" error instead of the generic 403 re-auth hint.

The REST-based Google MCPs in this repo (`google-gmail`, `google-drive`, `google-analytics`, ...) call the REST APIs, not Google's MCP servers, so they keep working either way.

## Tool definitions are committed snapshots

Tool names, descriptions, JSON schemas and PRM scopes for each backend live in `server/tools/generated/<service>.json`. These are committed to the repo for reproducible builds.
Expand Down
16 changes: 16 additions & 0 deletions google-workspace/server/lib/mcp-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test";
import { notAgenticError } from "./mcp-proxy.ts";

test("explains Google's not-agentic rejection instead of a re-auth hint", () => {
const err = notAgenticError(
"The client is not marked as an AI agent. See https://...",
);
expect(err?.message).toContain("not marked as agentic");
expect(err?.message).toContain("Re-authenticating will not help");
});

test("leaves ordinary errors alone", () => {
expect(
notAgenticError("Request had insufficient authentication scopes."),
).toBeNull();
});
36 changes: 30 additions & 6 deletions google-workspace/server/lib/mcp-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ interface JsonRpcError {

type JsonRpcResponse = JsonRpcSuccess | JsonRpcError;

/**
* From 2026-11-11 Google's Workspace MCP servers only accept OAuth clients
* flagged "agentic" (is_agentic) in Google Cloud Console; the rejection says
* the client "is not marked as an AI agent". Re-authenticating cannot fix
* that, so it must not be reported as a missing scope.
*/
export function notAgenticError(text: string): Error | null {
if (!/agentic|ai agent/i.test(text)) return null;
return new Error(
`Google rejected this MCP's OAuth client because it is not marked as agentic (is_agentic). ` +
`Re-authenticating will not help: a Google Cloud admin must enable the agentic setting on the ` +
`OAuth client (GOOGLE_WORKSPACE_CLIENT_ID, or GOOGLE_CLIENT_ID if unset). Upstream: ${text}`,
);
}

export async function proxyMcpCall(
backendUrl: string,
toolName: string,
Expand Down Expand Up @@ -51,23 +66,32 @@ export async function proxyMcpCall(

if (res.status === 403) {
const text = await res.text();
throw new Error(
`Google ${backendUrl} returned 403 Forbidden. The token is missing the required scope. Re-authenticate to grant the new permission. Body: ${text}`,
throw (
notAgenticError(text) ??
new Error(
`Google ${backendUrl} returned 403 Forbidden. The token is missing the required scope. Re-authenticate to grant the new permission. Body: ${text}`,
)
);
}

if (!res.ok) {
const text = await res.text();
throw new Error(
`Google MCP backend error ${res.status} on ${toolName}: ${text}`,
throw (
notAgenticError(text) ??
new Error(
`Google MCP backend error ${res.status} on ${toolName}: ${text}`,
)
);
}

const json = (await res.json()) as JsonRpcResponse;

if ("error" in json) {
throw new Error(
`Google MCP JSON-RPC error on ${toolName}: ${json.error.message} (code ${json.error.code})`,
throw (
notAgenticError(json.error.message) ??
new Error(
`Google MCP JSON-RPC error on ${toolName}: ${json.error.message} (code ${json.error.code})`,
)
);
}

Expand Down
10 changes: 9 additions & 1 deletion google-workspace/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ const runtime = withRuntime<Env, typeof StateSchema, Registry>({
},
tools: (env: Env) => tools.map((createTool) => createTool(env)),
prompts: () => prompts,
oauth: createGoogleOAuth({ scopes: GOOGLE_WORKSPACE_SCOPES }),
// Google's Workspace MCPs only accept OAuth clients marked "agentic". These
// optional vars let this MCP use its own agentic client instead of the
// GOOGLE_CLIENT_ID shared with the REST-based Google MCPs. Switching clients
// invalidates existing refresh tokens, so every user reconnects once.
oauth: createGoogleOAuth({
scopes: GOOGLE_WORKSPACE_SCOPES,
clientId: process.env.GOOGLE_WORKSPACE_CLIENT_ID || undefined,
clientSecret: process.env.GOOGLE_WORKSPACE_CLIENT_SECRET || undefined,
}),
});

serve(runtime.fetch);
Loading