diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index ebe823b..ac94a85 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -79,6 +79,7 @@ import * as front from './intl/front.json'; import * as ghost from './intl/ghost.json'; import * as gitbook from './intl/gitbook.json'; import * as gocardless from './intl/gocardless.json'; +import * as googleAnalytics4 from './intl/google-analytics-4.json'; import * as gorgias from './intl/gorgias.json'; import * as greenhouse from './intl/greenhouse.json'; import * as hackernews from './intl/hackernews.json'; @@ -321,6 +322,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ ghost as unknown as AdapterDefinition, gitbook as unknown as AdapterDefinition, gocardless as unknown as AdapterDefinition, + googleAnalytics4 as unknown as AdapterDefinition, gorgias as unknown as AdapterDefinition, greenhouse as unknown as AdapterDefinition, hackernews as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/google-analytics-4.json b/packages/backend/src/adapters/intl/google-analytics-4.json new file mode 100644 index 0000000..5539d68 --- /dev/null +++ b/packages/backend/src/adapters/intl/google-analytics-4.json @@ -0,0 +1,227 @@ +{ + "slug": "google-analytics-4", + "name": "Google Analytics 4", + "description": "Query Google Analytics 4 (account/property metadata, custom dimensions/metrics, core reports, realtime reports, funnel reports) from any AI agent. 8 tools, OAuth2 user auth.", + "instructions": "This connector wraps the Google Analytics Data API v1beta (analyticsdata.googleapis.com) and Admin API v1beta (analyticsadmin.googleapis.com) — the same two APIs the official googleanalytics/google-analytics-mcp server uses.\n\n**Setup — OAuth2 user flow (one-time per account)**:\n1. Go to https://console.cloud.google.com → pick or create a project.\n2. **APIs & Services → Library** → enable both:\n - **Google Analytics Admin API** (https://console.cloud.google.com/apis/library/analyticsadmin.googleapis.com)\n - **Google Analytics Data API** (https://console.cloud.google.com/apis/library/analyticsdata.googleapis.com)\n3. **APIs & Services → Credentials → Create Credentials → OAuth client ID** → application type 'Desktop app' (simplest for one-off auth). Add `https://developers.google.com/oauthplayground` as redirect URI if you'll mint the token there.\n4. Download the client JSON (or copy the `client_id` + `client_secret`).\n5. **Mint a refresh_token** — three options, pick what suits you:\n - **Easiest**: https://developers.google.com/oauthplayground → ⚙️ → 'Use your own OAuth credentials' → paste client_id/secret → scope `https://www.googleapis.com/auth/analytics.readonly` → 'Authorize APIs' → log in with the Google account that owns the GA property → 'Exchange authorization code for tokens' → copy the **Refresh token**.\n - **gcloud**: `gcloud auth application-default login --scopes=https://www.googleapis.com/auth/analytics.readonly --client-id-file=YOUR_CLIENT_JSON_FILE` → then grep `refresh_token` from `~/.config/gcloud/application_default_credentials.json`.\n - **Custom flow**: bake the standard OAuth2 dance into your app's UI.\n6. Set `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REFRESH_TOKEN`.\n\n**Authentication**: OAuth2 with refresh — the engine exchanges `refresh_token → access_token` against `https://oauth2.googleapis.com/token` automatically and sends `Authorization: Bearer ACCESS_TOKEN` on every call. Tokens are cached and refreshed on 401.\n\n**Service accounts are NOT supported yet** — they require JWT RS256 signing which our engine doesn't do. Workaround: create a dedicated 'automation' Google account, grant it Viewer access to the GA properties you care about, and mint the refresh_token from that account. The token never expires unless the account password changes or the OAuth grant is revoked.\n\n**Two hosts in one adapter**: tools transparently call either Admin (admin endpoints) or Data (report endpoints) — paths are absolute URLs so the engine routes correctly. No extra config needed.\n\n**Property IDs**: GA4 properties are 9-12 digit numeric IDs (NOT the measurement ID `G-XXXXXXXX`, NOT the Universal Analytics view ID). Find them via `ga4_get_account_summaries` (the first call you should make) or in the GA admin UI under Property Settings → Property details.\n\n**Reports — `runReport` body shape**:\n- `dimensions`: array of `{name: 'pagePath'}` — see https://ga.dev/api/data/v1/dimensions for the full list (~190 built-in).\n- `metrics`: array of `{name: 'sessions'}` — see https://ga.dev/api/data/v1/metrics (~80 built-in).\n- `dateRanges`: array of `{startDate: '2025-01-01', endDate: 'today'}`. `startDate` accepts `YYYY-MM-DD`, `today`, `yesterday`, or `NdaysAgo`.\n- `dimensionFilter` / `metricFilter`: optional Filter expressions.\n- `limit` (default 10k, max 250k), `offset`, `orderBys`.\n\n**Realtime**: `runRealtimeReport` looks at the last 30 minutes. Only a subset of dimensions/metrics work here (those with `realtime` in their docs).\n\n**Funnel reports**: `runFunnelReport` is in v1alpha — uses a multi-step funnel definition. See instructions in tool description.\n\n**Quotas**:\n- Data API: 200k requests/day per project, 5k tokens/hour, 25k tokens/day. Token cost depends on report complexity — see https://ga.dev/api/data/v1/quotas.\n- Admin API: 1500 requests/hour per project, 100k/day.\n- 429 → exponential backoff (engine handles retry on quota errors).\n\n**Out of scope here**: BigQuery export, GTM (Tag Manager) — separate APIs, would be a sibling adapter.", + "region": "intl", + "category": "analytics", + "icon": "google-analytics-4", + "docsUrl": "https://developers.google.com/analytics", + "requiredEnvVars": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REFRESH_TOKEN"], + "connector": { + "name": "Google Analytics 4 (Admin + Data API)", + "type": "REST", + "baseUrl": "https://analyticsadmin.googleapis.com", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{GOOGLE_CLIENT_ID}}", + "clientSecret": "{{GOOGLE_CLIENT_SECRET}}", + "refreshToken": "{{GOOGLE_REFRESH_TOKEN}}", + "tokenUrl": "https://oauth2.googleapis.com/token" + } + }, + "tools": [ + { + "name": "ga4_get_account_summaries", + "description": "List the user's GA4 accounts and their properties (account ID, account name, property IDs, display names). Always call this first — every other tool needs a property ID (the 9-12 digit numeric value, NOT the G-XXX measurement ID). Paginated via pageToken.", + "parameters": { + "type": "object", + "properties": { + "pageSize": { "type": "integer", "description": "Max accounts per page (default 50, max 200)." }, + "pageToken": { "type": "string", "description": "Cursor from previous response." } + } + }, + "endpointMapping": { + "method": "GET", + "path": "https://analyticsadmin.googleapis.com/v1beta/accountSummaries", + "queryParams": { + "pageSize": "$pageSize", + "pageToken": "$pageToken" + } + } + }, + { + "name": "ga4_get_property_details", + "description": "Get detailed info about one GA4 property: display name, create time, time zone, currency, parent account, industry category, service level, account.", + "parameters": { + "type": "object", + "properties": { + "property_id": { "type": "string", "description": "GA4 property ID (numeric, e.g. '123456789')." } + }, + "required": ["property_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://analyticsadmin.googleapis.com/v1beta/properties/{property_id}" + } + }, + { + "name": "ga4_list_google_ads_links", + "description": "List Google Ads accounts linked to a GA4 property (useful to confirm conversion attribution wiring).", + "parameters": { + "type": "object", + "properties": { + "property_id": { "type": "string", "description": "GA4 property ID." }, + "pageSize": { "type": "integer", "description": "Max per page." }, + "pageToken": { "type": "string", "description": "Pagination cursor." } + }, + "required": ["property_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://analyticsadmin.googleapis.com/v1beta/properties/{property_id}/googleAdsLinks", + "queryParams": { + "pageSize": "$pageSize", + "pageToken": "$pageToken" + } + } + }, + { + "name": "ga4_list_custom_dimensions", + "description": "List custom dimensions defined on a property — use these `parameterName` values in `dimensions` of ga4_run_report. Useful before authoring a report to discover what's available.", + "parameters": { + "type": "object", + "properties": { + "property_id": { "type": "string", "description": "GA4 property ID." }, + "pageSize": { "type": "integer", "description": "Max per page." }, + "pageToken": { "type": "string", "description": "Pagination cursor." } + }, + "required": ["property_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://analyticsadmin.googleapis.com/v1beta/properties/{property_id}/customDimensions", + "queryParams": { + "pageSize": "$pageSize", + "pageToken": "$pageToken" + } + } + }, + { + "name": "ga4_list_custom_metrics", + "description": "List custom metrics defined on a property — use these `parameterName` values in `metrics` of ga4_run_report.", + "parameters": { + "type": "object", + "properties": { + "property_id": { "type": "string", "description": "GA4 property ID." }, + "pageSize": { "type": "integer", "description": "Max per page." }, + "pageToken": { "type": "string", "description": "Pagination cursor." } + }, + "required": ["property_id"] + }, + "endpointMapping": { + "method": "GET", + "path": "https://analyticsadmin.googleapis.com/v1beta/properties/{property_id}/customMetrics", + "queryParams": { + "pageSize": "$pageSize", + "pageToken": "$pageToken" + } + } + }, + { + "name": "ga4_run_report", + "description": "Run a core analytics report. The most common tool you'll use. Returns rows with one column per dimension + one per metric. Example bodies in instructions. Use dimensions/metrics names from https://ga.dev/api/data/v1/dimensions and https://ga.dev/api/data/v1/metrics, plus any custom ones from ga4_list_custom_dimensions/metrics.", + "parameters": { + "type": "object", + "properties": { + "property_id": { "type": "string", "description": "GA4 property ID." }, + "dimensions": { "type": "array", "description": "Array of {name: ''} — e.g. [{name:'country'}, {name:'deviceCategory'}]." }, + "metrics": { "type": "array", "description": "Array of {name: ''} — e.g. [{name:'activeUsers'}, {name:'sessions'}]." }, + "dateRanges": { "type": "array", "description": "Array of {startDate, endDate, name?} — dates as YYYY-MM-DD / 'today' / 'yesterday' / 'NdaysAgo'." }, + "dimensionFilter": { "type": "object", "description": "Filter expression. e.g. {filter:{fieldName:'country', stringFilter:{value:'Italy'}}}." }, + "metricFilter": { "type": "object", "description": "Filter expression on metrics. e.g. {filter:{fieldName:'sessions', numericFilter:{operation:'GREATER_THAN', value:{int64Value:100}}}}." }, + "orderBys": { "type": "array", "description": "Sort. e.g. [{metric:{metricName:'sessions'}, desc:true}]." }, + "limit": { "type": "string", "description": "Max rows (default 10000, max 250000). String because GA accepts int64." }, + "offset": { "type": "string", "description": "Pagination offset (string int64)." }, + "keepEmptyRows": { "type": "boolean", "description": "Include zero-value rows." }, + "currencyCode": { "type": "string", "description": "ISO 4217 to override property default." }, + "returnPropertyQuota": { "type": "boolean", "description": "If true include `propertyQuota` block in response for cost diagnostics." } + }, + "required": ["property_id", "dimensions", "metrics", "dateRanges"] + }, + "endpointMapping": { + "method": "POST", + "path": "https://analyticsdata.googleapis.com/v1beta/properties/{property_id}:runReport", + "bodyMapping": { + "dimensions": "$dimensions", + "metrics": "$metrics", + "dateRanges": "$dateRanges", + "dimensionFilter": "$dimensionFilter", + "metricFilter": "$metricFilter", + "orderBys": "$orderBys", + "limit": "$limit", + "offset": "$offset", + "keepEmptyRows": "$keepEmptyRows", + "currencyCode": "$currencyCode", + "returnPropertyQuota": "$returnPropertyQuota" + } + } + }, + { + "name": "ga4_run_realtime_report", + "description": "Run a realtime report (last 30 minutes of activity). Only a subset of dimensions/metrics work here — see https://ga.dev/api/data/v1/realtime-dimensions and https://ga.dev/api/data/v1/realtime-metrics. Use for 'how many users are on the site right now', 'which pages are hot in the last minute', etc.", + "parameters": { + "type": "object", + "properties": { + "property_id": { "type": "string", "description": "GA4 property ID." }, + "dimensions": { "type": "array", "description": "Realtime dimensions only — e.g. [{name:'country'}, {name:'unifiedScreenName'}]." }, + "metrics": { "type": "array", "description": "Realtime metrics — e.g. [{name:'activeUsers'}]." }, + "dimensionFilter": { "type": "object", "description": "Optional dimension filter expression." }, + "metricFilter": { "type": "object", "description": "Optional metric filter." }, + "orderBys": { "type": "array", "description": "Sort." }, + "limit": { "type": "string", "description": "Max rows (default 10000)." }, + "minuteRanges": { "type": "array", "description": "Restrict to specific minute windows (default last 30 min). [{startMinutesAgo:30, endMinutesAgo:0}]." } + }, + "required": ["property_id", "metrics"] + }, + "endpointMapping": { + "method": "POST", + "path": "https://analyticsdata.googleapis.com/v1beta/properties/{property_id}:runRealtimeReport", + "bodyMapping": { + "dimensions": "$dimensions", + "metrics": "$metrics", + "dimensionFilter": "$dimensionFilter", + "metricFilter": "$metricFilter", + "orderBys": "$orderBys", + "limit": "$limit", + "minuteRanges": "$minuteRanges" + } + } + }, + { + "name": "ga4_run_funnel_report", + "description": "Run a funnel report — measures conversion at each step of a user journey (e.g. landed → viewed product → added to cart → purchased). Uses v1alpha endpoint. `funnel` defines the steps; each step is a filter on events/dimensions.", + "parameters": { + "type": "object", + "properties": { + "property_id": { "type": "string", "description": "GA4 property ID." }, + "dateRanges": { "type": "array", "description": "Array of {startDate, endDate}." }, + "funnel": { "type": "object", "description": "Funnel definition: {steps:[{name:'Landed', filterExpression:{...}}, {name:'Added to cart', filterExpression:{...}}], isOpenFunnel: false}." }, + "funnelBreakdown": { "type": "object", "description": "Optional breakdown by a dimension at each step: {breakdownDimension:{name:'deviceCategory'}, limit:5}." }, + "funnelNextAction": { "type": "object", "description": "Optional 'next action' analysis: shows what users did at each step." }, + "funnelVisualizationType": { "type": "string", "description": "STANDARD_FUNNEL or TRENDED_FUNNEL." }, + "segments": { "type": "array", "description": "Optional user/session segments to compare." }, + "limit": { "type": "string", "description": "Max rows." }, + "dimensionFilter": { "type": "object", "description": "Global filter applied across all steps." }, + "returnPropertyQuota": { "type": "boolean", "description": "Include quota info in response." } + }, + "required": ["property_id", "dateRanges", "funnel"] + }, + "endpointMapping": { + "method": "POST", + "path": "https://analyticsdata.googleapis.com/v1alpha/properties/{property_id}:runFunnelReport", + "bodyMapping": { + "dateRanges": "$dateRanges", + "funnel": "$funnel", + "funnelBreakdown": "$funnelBreakdown", + "funnelNextAction": "$funnelNextAction", + "funnelVisualizationType": "$funnelVisualizationType", + "segments": "$segments", + "limit": "$limit", + "dimensionFilter": "$dimensionFilter", + "returnPropertyQuota": "$returnPropertyQuota" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/google-analytics-4.live.spec.ts b/packages/backend/src/adapters/intl/google-analytics-4.live.spec.ts new file mode 100644 index 0000000..bcb0dab --- /dev/null +++ b/packages/backend/src/adapters/intl/google-analytics-4.live.spec.ts @@ -0,0 +1,52 @@ +import * as adapter from './google-analytics-4.json'; +const a = adapter as unknown as { + connector: { baseUrl: string; authType: string; authConfig: { tokenUrl: string } }; + tools: Array<{ name: string; endpointMapping: { path: string } }>; +}; +describe('google-analytics-4 adapter — static spec conformance', () => { + it('baseUrl points at the Admin host (Data endpoints use absolute URLs)', () => + expect(a.connector.baseUrl).toBe('https://analyticsadmin.googleapis.com')); + it('OAuth2 against the standard Google token endpoint', () => { + expect(a.connector.authType).toBe('OAUTH2'); + expect(a.connector.authConfig.tokenUrl).toBe('https://oauth2.googleapis.com/token'); + }); + it('reporting tools target analyticsdata.googleapis.com via absolute URL', () => { + // The engine routes absolute URLs out of the connector's baseUrl, so a + // single adapter can hit both googleanalytics hosts. Regression-guard + // the Data API hostname so a refactor doesn't silently drop reports + // onto the Admin host (where they 404). + const reportTools = a.tools.filter((t) => /_run_.*report$/.test(t.name)); + expect(reportTools.length).toBeGreaterThanOrEqual(3); + for (const t of reportTools) { + try { + const u = new URL(t.endpointMapping.path); + expect(u.hostname).toBe('analyticsdata.googleapis.com'); + } catch { + throw new Error(`${t.name} path is not an absolute URL: ${t.endpointMapping.path}`); + } + } + }); + it('admin metadata tools target analyticsadmin.googleapis.com', () => { + const adminTools = a.tools.filter( + (t) => /get_(account|property)|list_(google_ads|custom_)/.test(t.name), + ); + expect(adminTools.length).toBeGreaterThanOrEqual(3); + for (const t of adminTools) { + const u = new URL(t.endpointMapping.path); + expect(u.hostname).toBe('analyticsadmin.googleapis.com'); + } + }); + it('exposes the 7 tools mirroring the upstream MCP server', () => { + const names = a.tools.map((t) => t.name).sort(); + expect(names).toEqual([ + 'ga4_get_account_summaries', + 'ga4_get_property_details', + 'ga4_list_custom_dimensions', + 'ga4_list_custom_metrics', + 'ga4_list_google_ads_links', + 'ga4_run_funnel_report', + 'ga4_run_realtime_report', + 'ga4_run_report', + ]); + }); +}); diff --git a/packages/frontend/public/logos/connectors/google-analytics-4.svg b/packages/frontend/public/logos/connectors/google-analytics-4.svg new file mode 100644 index 0000000..544c5f5 --- /dev/null +++ b/packages/frontend/public/logos/connectors/google-analytics-4.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/frontend/src/app/connectors/store/page.tsx b/packages/frontend/src/app/connectors/store/page.tsx index c6ff851..e75d440 100644 --- a/packages/frontend/src/app/connectors/store/page.tsx +++ b/packages/frontend/src/app/connectors/store/page.tsx @@ -3,6 +3,8 @@ import Link from 'next/link'; import { Suspense, useEffect, useState, useRef } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { useAuth } from '@/lib/auth-context'; import { adapters } from '@/lib/api'; import { NavBar } from '@/components/nav-bar'; @@ -146,6 +148,10 @@ interface AdapterItem { } interface AdapterDetail extends AdapterItem { + // Long-form, Markdown-formatted help authored on the adapter JSON. + // Rendered inside the install modal so users see "where to find your + // refresh_token", auth-flow gotchas, etc. without leaving the page. + instructions?: string; connector: { name: string; type: string; @@ -177,6 +183,10 @@ function AdapterStoreContent() { // Credential modal state const [configAdapter, setConfigAdapter] = useState(null); const [credentialValues, setCredentialValues] = useState>({}); + // Per-field reveal toggle for password-masked credential inputs in the + // install modal. Keyed by env var name so each "Show/Hide" button + // toggles only its own field. Reset together with credentialValues. + const [revealedCredentials, setRevealedCredentials] = useState>({}); const [configLoading, setConfigLoading] = useState(false); // MCP assignment modal state @@ -227,6 +237,7 @@ function AdapterStoreContent() { const detail = await adapters.get(adapter.slug, token); setConfigAdapter(detail); setCredentialValues({}); + setRevealedCredentials({}); } catch { // Fallback: use list data setConfigAdapter({ @@ -234,6 +245,7 @@ function AdapterStoreContent() { connector: { name: adapter.name, type: 'REST', baseUrl: '', authType: 'API_KEY' }, } as AdapterDetail); setCredentialValues({}); + setRevealedCredentials({}); } finally { setConfigLoading(false); } @@ -506,30 +518,75 @@ function AdapterStoreContent() { Auth type: {AUTH_LABELS[configAdapter.connector?.authType] || configAdapter.connector?.authType} -
- {configAdapter.requiredEnvVars.map((envVar) => ( -
- - - setCredentialValues((prev) => ({ - ...prev, - [envVar]: e.target.value, - })) - } - placeholder={envVar} - className="w-full border border-[var(--input)] rounded-md px-3 py-2 text-sm bg-[var(--background)]" - /> + {/* Setup instructions — collapsible details block, default open + so first-time users see how to obtain each credential. The + content is the same Markdown stored on the adapter JSON's + `instructions` field. */} + {configAdapter.instructions && ( +
+ + 📖 How to get these credentials + +
+ + {configAdapter.instructions} +
- ))} +
+ )} + +
+ {configAdapter.requiredEnvVars.map((envVar) => { + const isSecret = + envVar.toLowerCase().includes('secret') || + envVar.toLowerCase().includes('password') || + envVar.toLowerCase().includes('token') || + envVar.toLowerCase().includes('key'); + const visible = revealedCredentials[envVar] === true; + return ( +
+ +
+ + setCredentialValues((prev) => ({ + ...prev, + [envVar]: e.target.value, + })) + } + placeholder={envVar} + className={ + 'w-full border border-[var(--input)] rounded-md px-3 py-2 text-sm bg-[var(--background)] ' + + (isSecret ? 'pr-16' : '') + } + /> + {isSecret && ( + + )} +
+
+ ); + })}