diff --git a/google-analytics/server/lib/ga-client.ts b/google-analytics/server/lib/ga-client.ts index 005335bf..e41c2517 100644 --- a/google-analytics/server/lib/ga-client.ts +++ b/google-analytics/server/lib/ga-client.ts @@ -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. @@ -107,7 +109,7 @@ export class GaClient { async runFunnelReport(property: string, body: object): Promise { 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), }); diff --git a/google-workspace/README.md b/google-workspace/README.md index 5a0ef506..dd7cbf1a 100644 --- a/google-workspace/README.md +++ b/google-workspace/README.md @@ -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/.json`. These are committed to the repo for reproducible builds. diff --git a/google-workspace/server/lib/mcp-proxy.test.ts b/google-workspace/server/lib/mcp-proxy.test.ts new file mode 100644 index 00000000..8f5e6caa --- /dev/null +++ b/google-workspace/server/lib/mcp-proxy.test.ts @@ -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(); +}); diff --git a/google-workspace/server/lib/mcp-proxy.ts b/google-workspace/server/lib/mcp-proxy.ts index 36f843bf..b42b7205 100644 --- a/google-workspace/server/lib/mcp-proxy.ts +++ b/google-workspace/server/lib/mcp-proxy.ts @@ -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, @@ -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})`, + ) ); } diff --git a/google-workspace/server/main.ts b/google-workspace/server/main.ts index 961d3693..586417d3 100644 --- a/google-workspace/server/main.ts +++ b/google-workspace/server/main.ts @@ -17,7 +17,15 @@ const runtime = withRuntime({ }, 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);