Skip to main content
Glama

Google Ads MCP Server

by martechery
server-tools.ts46.1 kB
// Accept any MCP server that exposes tool() and connect() type ToolHandler = (input?: unknown) => Promise<unknown> | unknown; type ToolServer = { tool: (def: any, handler: ToolHandler) => void }; import { executeGaql } from './tools/gaql.js'; import { listAccessibleCustomers } from './tools/accounts.js'; import { buildPerformanceQuery } from './tools/performance.js'; import { tabulate } from './utils/formatTable.js'; import { searchGoogleAdsFields } from './tools/fields.js'; import { gaqlHelp } from './tools/gaqlHelp.js'; import { mapAdsErrorMsg } from './utils/errorMapping.js'; import { microsToUnits } from './utils/currency.js'; import { ManageAuthZ, ListResourcesZ, ExecuteGaqlZ, GetPerformanceZ, GaqlHelpZ, SetSessionCredentialsZ, GetCredentialStatusZ, EndSessionZ, RefreshAccessTokenZ } from './schemas.js'; import { establishSession, endSession as endSessionConn, getCredentialStatus, requireSessionKeyIfEnabled, isCustomerAllowedForSession, refreshAccessTokenForSession, verifyTokenScopeForSession, checkRateLimit } from './utils/connection-manager.js'; import { emitMcpEvent, nowIso } from './utils/observability.js'; import { normalizeApiVersion } from './utils/normalizeApiVersion.js'; import { validateSessionKey } from './utils/session-validator.js'; function addTool(server: any, name: string, description: string, zodSchema: any, handler: ToolHandler) { // If this looks like our test FakeServer (captures tools in an object), use def-object style if (server && typeof server.tools === 'object') { return server.tool({ name, description, input_schema: {} }, handler); } // Prefer McpServer signature using paramsSchema (ZodRawShape), then callback. // If a ZodObject was provided, extract its raw shape. let shape = zodSchema; try { if (zodSchema && zodSchema._def && typeof zodSchema._def.shape === 'function') { shape = zodSchema._def.shape(); } } catch (e) { // ignore and use provided schema as-is } return server.tool(name, description, shape, handler); } export function registerTools(server: ToolServer) { // Removed: ping and get_auth_status (status merged into manage_auth) function logEvent(tool: string, start: number, opts: { sessionKey?: string; customerId?: string; requestId?: string; error?: { code: string; message: string } }) { const apiVer = normalizeApiVersion(process.env.GOOGLE_ADS_API_VERSION); emitMcpEvent({ timestamp: nowIso(), tool, session_key: opts.sessionKey, customer_id: opts.customerId, request_id: opts.requestId, response_time_ms: Date.now() - start, api_version: apiVer, error: opts.error, }); } // Manage auth tool (status implemented) addTool( server, "manage_auth", "Manage Google Ads auth: status; switch/refresh via gcloud; set_project/set_quota_project; optional oauth_login using env client id/secret to create ADC file.", ManageAuthZ, async (input: any) => { const startTs = Date.now(); if (process.env.ENABLE_RUNTIME_CREDENTIALS === 'true') { const out = { content: [{ type: 'text', text: 'Authentication cannot be modified in multi-tenant mode (manage_auth disabled).' }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id, error: { code: 'DISABLED_MULTI_TENANT', message: 'manage_auth disabled in multi-tenant mode' } }); return out; } const action = (input?.action || 'status').toLowerCase(); // Default: execute subprocess actions unless explicitly disabled const allowSub = input?.allow_subprocess !== false; const useLocalExec = process.env.VITEST_REAL === '1'; // Minimal local exec wrapper to avoid test-mocking pitfalls function localExec(cmd: string, args: string[], opts?: { timeoutMs?: number }): Promise<{ code: number; stdout: string; stderr: string }>{ return new Promise((resolve) => { try { import('node:child_process').then(({ spawn }) => { const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; let err = ''; child.stdout?.on('data', (d) => { try { out += String(d); } catch { void 0; } }); child.stderr?.on('data', (d) => { try { err += String(d); } catch { void 0; } }); let timeout: NodeJS.Timeout | undefined; const finalize = (code: number) => { if (timeout) clearTimeout(timeout); resolve({ code, stdout: out, stderr: err }); }; child.on('error', () => finalize(1)); child.on('close', (code) => finalize(code ?? 1)); if (opts?.timeoutMs && opts.timeoutMs > 0) { timeout = setTimeout(() => { try { child.kill('SIGKILL'); } catch { void 0; } }, opts.timeoutMs); } }).catch(() => resolve({ code: 1, stdout: '', stderr: '' })); } catch { resolve({ code: 1, stdout: '', stderr: '' }); } }); } async function execCmd(cmd: string, args: string[], opts?: { timeoutMs?: number }) { if (useLocalExec) return localExec(cmd, args, opts); try { const mod = await import('./utils/exec.js'); const run = (mod as any).execCmd as (c: string, a: string[], o?: { timeoutMs?: number }) => Promise<{ code: number; stdout: string; stderr: string }>; return run(cmd, args, opts); } catch { return localExec(cmd, args, opts); } } async function isGcloudAvailable(): Promise<boolean> { const res = await execCmd('gcloud', ['--version'], { timeoutMs: 5_000 }); return res.code === 0; } if (action === 'status') { const accountId = process.env.GOOGLE_ADS_ACCOUNT_ID || "(not set)"; const managerAccountId = process.env.GOOGLE_ADS_MANAGER_ACCOUNT_ID || "(not set)"; const developerTokenRaw = process.env.GOOGLE_ADS_DEVELOPER_TOKEN; const gacEnv = process.env.GOOGLE_APPLICATION_CREDENTIALS || "(not set)"; const lines: string[] = [ 'Google Ads Auth Status', '=======================', 'Environment:', ` GOOGLE_APPLICATION_CREDENTIALS: ${gacEnv}`, ` GOOGLE_ADS_ACCOUNT_ID: ${accountId}`, ` GOOGLE_ADS_MANAGER_ACCOUNT_ID: ${managerAccountId}`, ` GOOGLE_ADS_DEVELOPER_TOKEN: ${developerTokenRaw ? "(set)" : "(not set)"}`, 'Notes:', "- ADC via gcloud is preferred for stability and auto-refresh.", '- You can also use an existing authorized_user JSON via GOOGLE_APPLICATION_CREDENTIALS.', '', 'Probes:', ]; try { const { token, quotaProjectId, type } = await (await import('./auth.js')).getAccessToken(); lines.push(` Auth type: ${type}`); lines.push(` Token present: ${token ? 'yes' : 'no'}`); lines.push(` Quota project: ${quotaProjectId || '(none)'}`); // Token info (scopes/audience) try { const infoRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(token)}`); if (infoRes.ok) { const info: any = await infoRes.json(); if (info.scope) lines.push(` Token scopes: ${info.scope}`); if (info.aud) lines.push(` Token audience: ${info.aud}`); if (info.azp) lines.push(` Token azp: ${info.azp}`); } else { lines.push(' Token info: (unavailable)'); } } catch { lines.push(' Token info: (error fetching)'); } // Probe scope by hitting listAccessibleCustomers const resp = await listAccessibleCustomers(); if (resp.ok) { lines.push(' Ads scope check: OK'); const count = resp.data?.resourceNames?.length || 0; lines.push(` Accessible accounts: ${count}`); } else if (resp.status === 403 && (resp.errorText || '').includes('ACCESS_TOKEN_SCOPE_INSUFFICIENT')) { lines.push(' Ads scope check: missing scope (ACCESS_TOKEN_SCOPE_INSUFFICIENT)'); } else { lines.push(` Ads scope check: HTTP ${resp.status}`); } } catch (e: any) { lines.push(` Error determining auth status: ${e?.message || String(e)}`); } // ADC file discovery hints (including local .auth/adc.json) try { const fs = await import('node:fs'); const path = await import('node:path'); const os = await import('node:os'); const hints: string[] = []; const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; const exists = (p?: string | null) => !!p && fs.existsSync(p); const localPath = path.resolve(process.cwd(), '.auth', 'adc.json'); let wellKnown: string | null = null; if (process.platform === 'win32') { const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); wellKnown = path.join(appData, 'gcloud', 'application_default_credentials.json'); } else { wellKnown = path.join(os.homedir(), '.config', 'gcloud', 'application_default_credentials.json'); } if (exists(envPath)) hints.push(` ADC file (env): ${envPath}`); else if (exists(localPath)) hints.push(` ADC file (project .auth): ${localPath}`); else if (exists(wellKnown)) hints.push(` ADC file (well-known): ${wellKnown}`); else hints.push(' ADC file: not found in env or well-known path'); lines.push('', 'ADC file discovery:', ...hints); if (!exists(envPath) && !exists(wellKnown)) { lines.push(' Hint: Install gcloud to create ADC via browser login, or provide an authorized_user JSON and set GOOGLE_APPLICATION_CREDENTIALS to its path.'); } } catch { // ignore discovery errors } const out = { content: [{ type: 'text', text: lines.join('\n') }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } if (action === 'oauth_login') { const clientId = (process.env.GOOGLE_OAUTH_CLIENT_ID || '').trim(); const clientSecret = (process.env.GOOGLE_OAUTH_CLIENT_SECRET || '').trim(); if (!clientId || !clientSecret) { const lines = [ 'OAuth credentials not available', 'Missing GOOGLE_OAUTH_CLIENT_ID and/or GOOGLE_OAUTH_CLIENT_SECRET.', 'Set both env vars to a Desktop app OAuth client and retry.', 'Preferred path remains: gcloud auth application-default login with Ads scope.', ]; const out = { content: [{ type: 'text', text: lines.join('\n') }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id, error: { code: 'OAUTH_CLIENT_MISSING', message: 'Missing client id/secret' } }); return out; } try { const mod = await import('./tools/oauth.js'); const { runDeviceOAuthForAds } = mod as any; const out = await runDeviceOAuthForAds({ clientId, clientSecret }); const lines = [ 'OAuth device flow completed.', `Saved ADC authorized_user JSON: ${out.path}`, '', 'Next steps for current shell (optional):', ` export GOOGLE_APPLICATION_CREDENTIALS="${out.path}"`, ]; // Verify scope by listing accounts try { const resp = await listAccessibleCustomers(); if (resp.ok) { lines.push('Ads scope check after oauth_login: OK'); const count = resp.data?.resourceNames?.length || 0; lines.push(`Accessible accounts: ${count}`); } else { lines.push(`Ads scope check after oauth_login: HTTP ${resp.status}`); } } catch { /* ignore */ } const resp = { content: [{ type: 'text', text: lines.join('\n') }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return resp; } catch (e: any) { const lines = [ 'OAuth device flow failed.', `Error: ${e?.message || String(e)}`, ]; const resp = { content: [{ type: 'text', text: lines.join('\n') }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id, error: { code: 'OAUTH_DEVICE_FAILED', message: String(e?.message || e) } }); return resp; } } if (action === 'switch') { const name = input?.config_name?.trim(); if (!name) { return { content: [{ type: 'text', text: "Missing 'config_name'. Example: { action: 'switch', config_name: 'my-config' }" }] }; } const cmd = `gcloud config configurations activate ${name}`; if (!allowSub) { const text = [ 'Planned action: switch gcloud configuration', `Command: ${cmd}`, 'Tip: Re-run with allow_subprocess=true (default) to execute from MCP, or set allow_subprocess=false to only print steps.', ].join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } if (!(await isGcloudAvailable())) { const text = [ 'gcloud not found on PATH. Cannot execute switch automatically.', `Please run manually: ${cmd}`, 'Install: https://cloud.google.com/sdk/docs/install', ].join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id, error: { code: 'GCLOUD_NOT_FOUND', message: 'gcloud not on PATH' } }); return out; } const { code, stdout, stderr } = await execCmd('gcloud', ['config', 'configurations', 'activate', name]); const lines = [ `gcloud switch (${name}) exit: ${code}`, stdout ? `stdout:\n${stdout}` : '', stderr ? `stderr:\n${stderr}` : '', 'Next: refresh ADC credentials to ensure Ads scope:', ' gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/adwords', ].filter(Boolean); const out = { content: [{ type: 'text', text: lines.join('\n') }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } if (action === 'set_project') { const projectId = (input?.project_id || input?.project || '').trim(); const allowSub = input?.allow_subprocess !== false; if (!projectId) return { content: [{ type: 'text', text: "Missing 'project_id'. Example: { action: 'set_project', project_id: 'my-project' }" }] }; const cmd = `gcloud config set project ${projectId}`; if (!allowSub) { const text = [ 'Planned action: set default gcloud project', `Command: ${cmd}`, ].join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } const { code, stdout, stderr } = await execCmd('gcloud', ['config', 'set', 'project', projectId]); const text = [ `gcloud set project exit: ${code}`, stdout ? `stdout:\n${stdout}` : '', stderr ? `stderr:\n${stderr}` : '', ].filter(Boolean).join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } if (action === 'set_quota_project') { const projectId = (input?.project_id || input?.project || '').trim(); const allowSub = input?.allow_subprocess !== false; if (!projectId) return { content: [{ type: 'text', text: "Missing 'project_id'. Example: { action: 'set_quota_project', project_id: 'my-project' }" }] }; const cmd = `gcloud auth application-default set-quota-project ${projectId}`; if (!allowSub) { const text = [ 'Planned action: set ADC quota project', `Command: ${cmd}`, ].join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } const { code, stdout, stderr } = await execCmd('gcloud', ['auth', 'application-default', 'set-quota-project', projectId]); const text = [ `gcloud set-quota-project exit: ${code}`, stdout ? `stdout:\n${stdout}` : '', stderr ? `stderr:\n${stderr}` : '', ].filter(Boolean).join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } if (action === 'refresh') { const loginCmd = 'gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/adwords'; if (!allowSub) { const text = [ 'Planned action: refresh ADC credentials for Ads scope', `Command: ${loginCmd}`, 'Tip: Re-run with allow_subprocess=true (default) to execute from MCP, or set allow_subprocess=false to only print steps.', ].join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } if (!(await isGcloudAvailable())) { const text = [ 'gcloud not found on PATH. Cannot execute refresh automatically.', `Please run manually: ${loginCmd}`, 'Install: https://cloud.google.com/sdk/docs/install', ].join('\n'); const out = { content: [{ type: 'text', text }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id, error: { code: 'GCLOUD_NOT_FOUND', message: 'gcloud not on PATH' } }); return out; } const step1 = await execCmd('gcloud', ['auth', 'application-default', 'login', '--scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/adwords']); // Verify by printing a token (will also surface scope issues) const step2 = await execCmd('gcloud', ['auth', 'application-default', 'print-access-token']); let check: any; try { const mod = await import('./tools/accounts.js'); const fn = (mod as any).listAccessibleCustomers || listAccessibleCustomers; check = await fn(); } catch { check = undefined; } const lines = [ `refresh login exit: ${step1.code}`, step1.stdout ? `login stdout:\n${step1.stdout}` : '', step1.stderr ? `login stderr:\n${step1.stderr}` : '', `print-token exit: ${step2.code}`, step2.stdout ? `token (truncated): ${step2.stdout.slice(0, 12)}...` : '', step2.stderr ? `print-token stderr:\n${step2.stderr}` : '', (check && check.ok) ? 'Ads scope check after refresh: OK' : (step2.code === 0 ? 'Ads scope check after refresh: OK (token printed)' : `Ads scope check after refresh: ${check ? `HTTP ${check.status}` : 'unknown'}`), ].filter(Boolean); if (step1.code !== 0) { lines.push('Install: https://cloud.google.com/sdk/docs/install'); } const out = { content: [{ type: 'text', text: lines.join('\n') }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id }); return out; } const out = { content: [{ type: 'text', text: `Unknown action '${action}'. Use status | switch | refresh.` }] }; logEvent('manage_auth', startTs, { requestId: input?.request_id, error: { code: 'ERR_UNKNOWN_ACTION', message: String(action) } }); return out; } ); // Execute GAQL query tool (basic) addTool( server, "execute_gaql_query", "Execute GAQL. Optional: login_customer_id (aka MCC/manager account id) overrides env.", ExecuteGaqlZ, async (_input: any) => { const input = (_input || {}) as any; const startTs = Date.now(); let sessionKey: string | undefined; try { sessionKey = requireSessionKeyIfEnabled(input); } catch (e: any) { const msg = e?.message || String(e); logEvent('execute_gaql_query', startTs, { sessionKey, customerId: input?.customer_id, requestId: input?.request_id, error: { code: 'ERR_INPUT', message: String(msg) } }); return { content: [{ type: 'text', text: `Error: ${msg}` }] }; } if (sessionKey) { const rc = checkRateLimit(sessionKey); if (!rc.allowed) { logEvent('execute_gaql_query', startTs, { sessionKey, customerId: input?.customer_id, requestId: input?.request_id, error: { code: 'ERR_RATE_LIMITED', message: `Retry after ${rc.retryAfter}s` } }); return { content: [{ type: 'text', text: JSON.stringify({ error: { code: 'ERR_RATE_LIMITED', message: `Rate limit exceeded. Retry after ${rc.retryAfter} seconds`, retry_after: rc.retryAfter } }) }] }; } } if (!input.customer_id) { const envAccount = process.env.GOOGLE_ADS_ACCOUNT_ID; if (envAccount) { input.customer_id = envAccount; } else { const res = await listAccessibleCustomers(sessionKey); if (!res.ok) { const hint = mapAdsErrorMsg(res.status, res.errorText || ''); const lines = [ 'No customer_id provided. Please choose an account and re-run with customer_id.', `Error listing accounts (status ${res.status}): ${res.errorText || ''}`, ]; if (hint) lines.push(`Hint: ${hint}`); logEvent('execute_gaql_query', startTs, { sessionKey, requestId: input?.request_id, error: { code: `HTTP_${res.status}`, message: String(res.errorText || '') } }); return { content: [{ type: 'text', text: lines.join('\n') }] }; } const names = res.data?.resourceNames || []; if (!names.length) return { content: [{ type: 'text', text: 'No accessible accounts found.' }] }; const rows = names.map((rn: string) => ({ account_id: (rn.split('/').pop() || rn) })); const table = tabulate(rows, ['account_id']); const lines = [ 'No customer_id provided. Select one of the accounts below, then call again with customer_id.', table, ]; logEvent('execute_gaql_query', startTs, { sessionKey, requestId: input?.request_id }); return { content: [{ type: 'text', text: lines.join('\n') }] }; } } // Enforce allowlist if present if (sessionKey && input.customer_id && !isCustomerAllowedForSession(sessionKey, input.customer_id)) { return { content: [{ type: 'text', text: `Error: Customer ID ${input.customer_id} not in allowlist for this session` }] }; } const auto = !!input.auto_paginate; const maxPages = Math.max(1, Math.min(20, Number(input.max_pages ?? 5))); const pageSize = (typeof input.page_size === 'number') ? Math.max(1, Math.min(10_000, Number(input.page_size))) : undefined; let pageToken = input.page_token as string | undefined; let all: any[] = []; let lastToken: string | undefined; let pageCount = 0; // Normalize MCC/login-customer aliases for robustness const loginCustomerId = (input as any).login_customer_id ?? (input as any).loginCustomerId ?? (input as any).managerAccountId ?? (input as any).mcc; do { const res = await executeGaql({ customerId: input.customer_id, query: input.query, pageSize, pageToken, loginCustomerId, sessionKey }); if (!res.ok) { const hint = mapAdsErrorMsg(res.status, res.errorText || ''); const lines = [`Error executing query (status ${res.status}): ${res.errorText || ''}`]; if (hint) lines.push(`Hint: ${hint}`); logEvent('execute_gaql_query', startTs, { sessionKey, customerId: input.customer_id, requestId: input?.request_id, error: { code: `HTTP_${res.status}`, message: String(res.errorText || '') } }); return { content: [{ type: "text", text: lines.join('\n') }] }; } const data = res.data; const results = (data?.results && Array.isArray(data.results)) ? data.results : []; all = all.concat(results); lastToken = data?.nextPageToken; pageToken = auto ? lastToken : undefined; pageCount++; } while (auto && pageToken && pageCount < maxPages); if (!all.length) { return { content: [{ type: "text", text: "No results found for the query." }] }; } const first = all[0]; const fields: string[] = []; for (const key of Object.keys(first)) { const val = (first as any)[key]; if (val && typeof val === "object" && !Array.isArray(val)) { for (const sub of Object.keys(val)) fields.push(`${key}.${sub}`); } else { fields.push(key); } } const fmt = (input.output_format || 'table').toLowerCase(); if (fmt === 'json') return { content: [{ type: 'text', text: JSON.stringify(all, null, 2) }] }; if (fmt === 'csv') { const { toCsv } = await import('./utils/formatCsv.js'); const csv = toCsv(all, fields); return { content: [{ type: 'text', text: csv }] }; } const table = tabulate(all, fields); const lines: string[] = ["Query Results:", table]; if (!auto && lastToken) lines.push(`Next Page Token: ${lastToken}`); if (auto) lines.push(`Pages fetched: ${pageCount}`); const out = { content: [{ type: "text", text: lines.join("\n") }] }; logEvent('execute_gaql_query', startTs, { sessionKey, customerId: input.customer_id, requestId: input?.request_id }); return out; } ); // List accessible accounts: removed for simplicity. Use list_resources(kind=accounts). // Unified performance tool addTool( server, "get_performance", "Get performance (level: account|campaign|ad_group|ad). Optional: login_customer_id (aka MCC/manager account id) overrides env.", GetPerformanceZ, async (_input: any) => { const input = (_input || {}) as any; const startTs = Date.now(); let sessionKey: string | undefined; try { sessionKey = requireSessionKeyIfEnabled(input); } catch (e: any) { const msg = e?.message || String(e); logEvent('get_performance', startTs, { sessionKey, customerId: input?.customer_id, requestId: input?.request_id, error: { code: 'ERR_INPUT', message: String(msg) } }); return { content: [{ type: 'text', text: `Error: ${msg}` }] }; } if (sessionKey) { const rc = checkRateLimit(sessionKey); if (!rc.allowed) { logEvent('get_performance', startTs, { sessionKey, customerId: input?.customer_id, requestId: input?.request_id, error: { code: 'ERR_RATE_LIMITED', message: `Retry after ${rc.retryAfter}s` } }); return { content: [{ type: 'text', text: JSON.stringify({ error: { code: 'ERR_RATE_LIMITED', message: `Rate limit exceeded. Retry after ${rc.retryAfter} seconds`, retry_after: rc.retryAfter } }) }] }; } } if (!input.customer_id) { const envAccount = process.env.GOOGLE_ADS_ACCOUNT_ID; if (envAccount) { input.customer_id = envAccount; } else { const res = await listAccessibleCustomers(sessionKey); if (!res.ok) { const hint = mapAdsErrorMsg(res.status, res.errorText || ''); const lines = [ 'No customer_id provided. Please choose an account and re-run with customer_id.', `Error listing accounts (status ${res.status}): ${res.errorText || ''}`, ]; if (hint) lines.push(`Hint: ${hint}`); logEvent('get_performance', startTs, { sessionKey, requestId: input?.request_id, error: { code: `HTTP_${res.status}`, message: String(res.errorText || '') } }); return { content: [{ type: 'text', text: lines.join('\n') }] }; } const names = res.data?.resourceNames || []; if (!names.length) return { content: [{ type: 'text', text: 'No accessible accounts found.' }] }; const rows = names.map((rn: string) => ({ account_id: (rn.split('/').pop() || rn) })); const table = tabulate(rows, ['account_id']); const lines = [ 'No customer_id provided. Select one of the accounts below, then call again with customer_id.', table, ]; logEvent('get_performance', startTs, { sessionKey, requestId: input?.request_id }); return { content: [{ type: 'text', text: lines.join('\n') }] }; } } // Enforce allowlist if present if (sessionKey && input.customer_id && !isCustomerAllowedForSession(sessionKey, input.customer_id)) { return { content: [{ type: 'text', text: `Error: Customer ID ${input.customer_id} not in allowlist for this session` }] }; } const days = Math.max(1, Math.min(365, Number(input.days ?? 30))); const limit = Math.max(1, Math.min(1000, Number(input.limit ?? 50))); const query = buildPerformanceQuery(input.level, days, limit, input.filters || {}); const auto = !!input.auto_paginate; const maxPages = Math.max(1, Math.min(20, Number(input.max_pages ?? 5))); const pageSize = (typeof input.page_size === 'number') ? Math.max(1, Math.min(10_000, Number(input.page_size))) : undefined; let pageToken = input.page_token as string | undefined; let all: any[] = []; let lastToken: string | undefined; let pageCount = 0; // Normalize MCC/login-customer aliases for robustness const loginCustomerId = (input as any).login_customer_id ?? (input as any).loginCustomerId ?? (input as any).managerAccountId ?? (input as any).mcc; do { const res = await executeGaql({ customerId: input.customer_id, query, pageSize, pageToken, loginCustomerId, sessionKey }); if (!res.ok) { const hint = mapAdsErrorMsg(res.status, res.errorText || ''); const lines = [`Error executing performance query (status ${res.status}): ${res.errorText || ''}`]; if (hint) lines.push(`Hint: ${hint}`); logEvent('get_performance', startTs, { sessionKey, customerId: input.customer_id, requestId: input?.request_id, error: { code: `HTTP_${res.status}`, message: String(res.errorText || '') } }); return { content: [{ type: "text", text: lines.join('\n') }] }; } const data = res.data; const results = (data?.results && Array.isArray(data.results)) ? data.results : []; all = all.concat(results); lastToken = data?.nextPageToken; pageToken = auto ? lastToken : undefined; pageCount++; } while (auto && pageToken && pageCount < maxPages); if (!all.length) { return { content: [{ type: "text", text: "No results found for the selected period." }] }; } const rows = (all as any[]).map((r: any) => { const out = { ...r }; const metrics = { ...(r?.metrics || {}) } as any; const micros = (metrics.cost_micros ?? metrics.costMicros); if (typeof micros === 'number') metrics.cost_units = microsToUnits(micros); (out as any).metrics = metrics; return out; }); const first = rows[0]; const fields: string[] = []; for (const key of Object.keys(first)) { const val = (first as any)[key]; if (val && typeof val === "object" && !Array.isArray(val)) { for (const sub of Object.keys(val)) fields.push(`${key}.${sub}`); } else { fields.push(key); } } const fmt = (input.output_format || 'table').toLowerCase(); if (fmt === 'json') return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] }; if (fmt === 'csv') { const { toCsv } = await import('./utils/formatCsv.js'); const csv = toCsv(rows, fields); return { content: [{ type: 'text', text: csv }] }; } const table = tabulate(rows, fields); const lines: string[] = [ `Performance (${input.level}) for last ${input.days ?? 30} days:`, table, ]; if (!auto && lastToken) lines.push(`Next Page Token: ${lastToken}`); if (auto) lines.push(`Pages fetched: ${pageCount}`); const out = { content: [{ type: "text", text: lines.join("\n") }] }; logEvent('get_performance', startTs, { sessionKey, customerId: input.customer_id, requestId: input?.request_id }); return out; } ); // List GAQL FROM resources (via google_ads_field metadata) or accounts addTool( server, "list_resources", "List GAQL FROM-able resources via google_ads_field (category=RESOURCE, selectable=true) or list accounts. output_format=table|json|csv.", ListResourcesZ, async (input: any) => { const startTs = Date.now(); let sessionKey: string | undefined; try { sessionKey = requireSessionKeyIfEnabled(input); } catch (e: any) { const msg = e?.message || String(e); logEvent('list_resources', startTs, { sessionKey, requestId: input?.request_id, error: { code: 'ERR_INPUT', message: String(msg) } }); return { content: [{ type: 'text', text: `Error: ${msg}` }] }; } if (sessionKey) { const rc = checkRateLimit(sessionKey); if (!rc.allowed) { logEvent('list_resources', startTs, { sessionKey, requestId: input?.request_id, error: { code: 'ERR_RATE_LIMITED', message: `Retry after ${rc.retryAfter}s` } }); return { content: [{ type: 'text', text: JSON.stringify({ error: { code: 'ERR_RATE_LIMITED', message: `Rate limit exceeded. Retry after ${rc.retryAfter} seconds`, retry_after: rc.retryAfter } }) }] }; } } const kind = String(input?.kind || 'resources').toLowerCase(); if (kind === 'accounts') { const res = await listAccessibleCustomers(sessionKey); if (!res.ok) { const hint = mapAdsErrorMsg(res.status, res.errorText || ''); const lines = [`Error listing accounts (status ${res.status}): ${res.errorText || ''}`]; if (hint) lines.push(`Hint: ${hint}`); logEvent('list_resources', startTs, { sessionKey, requestId: input?.request_id, error: { code: `HTTP_${res.status}`, message: String(res.errorText || '') } }); return { content: [{ type: 'text', text: lines.join('\n') }] }; } const names = res.data?.resourceNames || []; const rows = names.map((rn: string) => ({ account_id: (rn.split('/').pop() || rn) })); const fields = ['account_id']; const fmt = (input.output_format || 'table').toLowerCase(); if (fmt === 'json') return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] }; if (fmt === 'csv') { const { toCsv } = await import('./utils/formatCsv.js'); const csv = toCsv(rows, fields); return { content: [{ type: 'text', text: csv }] }; } const table = tabulate(rows, fields); const out = { content: [{ type: 'text', text: `Accounts:\n${table}` }] }; logEvent('list_resources', startTs, { sessionKey, requestId: input?.request_id }); return out; } const limit = Math.max(1, Math.min(1000, Number(input?.limit ?? 500))); const filter = (input?.filter || '').trim(); const where = ["category = 'RESOURCE'", 'selectable = true']; if (filter) where.push(`name LIKE '%${filter.replace(/'/g, "''")}%'`); // GoogleAdsFieldService search does NOT support FROM; use implicit FROM. const query = `SELECT name, category, selectable WHERE ${where.join(' AND ')} ORDER BY name LIMIT ${limit}`; const res = await searchGoogleAdsFields(query, sessionKey); if (!res.ok) { const hint = mapAdsErrorMsg(res.status, res.errorText || ''); const lines = [`Error listing resources (status ${res.status}): ${res.errorText || ''}`]; if (hint) lines.push(`Hint: ${hint}`); logEvent('list_resources', startTs, { sessionKey, requestId: input?.request_id, error: { code: `HTTP_${res.status}`, message: String(res.errorText || '') } }); return { content: [{ type: 'text', text: lines.join('\n') }] }; } const items = (res.data?.results || []).map((r: any) => ({ name: r.googleAdsField?.name, category: r.googleAdsField?.category, selectable: r.googleAdsField?.selectable })); if (!items.length) return { content: [{ type: 'text', text: 'No resources found.' }] }; const fields = ['name', 'category', 'selectable']; const fmt = (input.output_format || 'table').toLowerCase(); if (fmt === 'json') return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] }; if (fmt === 'csv') { const { toCsv } = await import('./utils/formatCsv.js'); const csv = toCsv(items, fields); return { content: [{ type: 'text', text: csv }] }; } const table = tabulate(items, fields); const out = { content: [{ type: 'text', text: `GAQL Resources:\n${table}` }] }; logEvent('list_resources', startTs, { sessionKey, requestId: input?.request_id }); return out; } ); // GAQL help: fetches and summarizes Google docs based on a question addTool( server, "gaql_help", "Get GAQL help with local documentation and official Google Ads API links. Use topic for specific areas or search for keywords.", GaqlHelpZ, async (input: any) => { const startTs = Date.now(); try { const text = await gaqlHelp({ topic: input?.topic, search: input?.search, }); const out = { content: [{ type: 'text', text }] }; logEvent('gaql_help', startTs, { requestId: input?.request_id }); return out; } catch (e: any) { const lines = [ `Error fetching GAQL help: ${e?.message || String(e)}`, 'Hint: set quick_tips=true to avoid network usage.', ]; const out = { content: [{ type: 'text', text: lines.join('\n') }] }; logEvent('gaql_help', startTs, { requestId: input?.request_id, error: { code: 'ERR_HELP', message: String(e?.message || e) } }); return out; } } ); // Multi-tenant session tools addTool( server, 'set_session_credentials', 'Establish a session with Google Ads credentials (multi-tenant mode only).', SetSessionCredentialsZ, async (input: any) => { const startTs = Date.now(); if (process.env.ENABLE_RUNTIME_CREDENTIALS !== 'true') { const out = { content: [{ type: 'text', text: 'Multi-tenant mode not enabled' }] }; logEvent('set_session_credentials', startTs, { requestId: input?.request_id, error: { code: 'ERR_NOT_ENABLED', message: 'Multi-tenant mode not enabled' } }); return out; } try { validateSessionKey(String(input?.session_key || '')); } catch (e: any) { const msg = e?.message || String(e); logEvent('set_session_credentials', startTs, { sessionKey: input?.session_key, requestId: input?.request_id, error: { code: 'ERR_INPUT', message: String(msg) } }); return { content: [{ type: 'text', text: `Error: ${msg}` }] }; } try { const out = establishSession(String(input.session_key), input.google_credentials); // Optional: verify Ads scope if enabled if (process.env.VERIFY_TOKEN_SCOPE === 'true') { try { await verifyTokenScopeForSession(String(input.session_key)); } catch (e: any) { const msg = String(e?.message || e); if (msg.startsWith('ERR_INSUFFICIENT_SCOPE')) { logEvent('set_session_credentials', startTs, { sessionKey: input?.session_key, requestId: input?.request_id, error: { code: 'ERR_INSUFFICIENT_SCOPE', message: 'Missing adwords scope' } }); return { content: [{ type: 'text', text: JSON.stringify({ error: { code: 'ERR_INSUFFICIENT_SCOPE', message: 'Access token lacks required Google Ads scope (adwords). Please re-authenticate with the correct scope.' } }) }] }; } logEvent('set_session_credentials', startTs, { sessionKey: input?.session_key, requestId: input?.request_id, error: { code: 'ERR_SCOPE_VERIFY_FAILED', message: msg } }); return { content: [{ type: 'text', text: JSON.stringify({ error: { code: 'ERR_SCOPE_VERIFY_FAILED', message: msg } }) }] }; } } const resp = { content: [{ type: 'text', text: JSON.stringify({ status: 'success', ...out }) }] }; logEvent('set_session_credentials', startTs, { sessionKey: input?.session_key, requestId: input?.request_id }); return resp; } catch (e: any) { const msg = e?.message || String(e); const code = msg.startsWith('ERR_IMMUTABLE_AUTH') ? 'ERR_IMMUTABLE_AUTH' : 'ERR_ESTABLISH'; logEvent('set_session_credentials', startTs, { sessionKey: input?.session_key, requestId: input?.request_id, error: { code, message: String(msg) } }); return { content: [{ type: 'text', text: JSON.stringify({ error: { code, message: msg } }) }] }; } } ); addTool( server, 'get_credential_status', 'Get credential status for a session (multi-tenant mode).', GetCredentialStatusZ, async (input: any) => { const startTs = Date.now(); if (process.env.ENABLE_RUNTIME_CREDENTIALS !== 'true') { const out = { content: [{ type: 'text', text: 'Multi-tenant mode not enabled' }] }; logEvent('get_credential_status', startTs, { requestId: input?.request_id, error: { code: 'ERR_NOT_ENABLED', message: 'Multi-tenant mode not enabled' } }); return out; } try { validateSessionKey(String(input?.session_key || '')); } catch (e: any) { const msg = e?.message || String(e); logEvent('get_credential_status', startTs, { sessionKey: input?.session_key, requestId: input?.request_id, error: { code: 'ERR_INPUT', message: String(msg) } }); return { content: [{ type: 'text', text: `Error: ${msg}` }] }; } const status = getCredentialStatus(String(input.session_key)); const out = { content: [{ type: 'text', text: JSON.stringify(status) }] }; logEvent('get_credential_status', startTs, { sessionKey: input?.session_key, requestId: input?.request_id }); return out; } ); addTool( server, 'end_session', 'End a session and clear credentials (multi-tenant mode).', EndSessionZ, async (input: any) => { const startTs = Date.now(); if (process.env.ENABLE_RUNTIME_CREDENTIALS !== 'true') { const out = { content: [{ type: 'text', text: 'Multi-tenant mode not enabled' }] }; logEvent('end_session', startTs, { requestId: input?.request_id, error: { code: 'ERR_NOT_ENABLED', message: 'Multi-tenant mode not enabled' } }); return out; } try { validateSessionKey(String(input?.session_key || '')); } catch (e: any) { const msg = e?.message || String(e); logEvent('end_session', startTs, { sessionKey: input?.session_key, requestId: input?.request_id, error: { code: 'ERR_INPUT', message: String(msg) } }); return { content: [{ type: 'text', text: `Error: ${msg}` }] }; } endSessionConn(String(input.session_key)); const out = { content: [{ type: 'text', text: JSON.stringify({ status: 'session_ended' }) }] }; logEvent('end_session', startTs, { sessionKey: input?.session_key, requestId: input?.request_id }); return out; } ); addTool( server, 'refresh_access_token', 'Refresh the access token for a session (multi-tenant mode). Requires GOOGLE_OAUTH_CLIENT_ID/SECRET.', RefreshAccessTokenZ, async (input: any) => { if (process.env.ENABLE_RUNTIME_CREDENTIALS !== 'true') { return { content: [{ type: 'text', text: 'Multi-tenant mode not enabled' }] }; } const clientId = (process.env.GOOGLE_OAUTH_CLIENT_ID || '').trim(); const clientSecret = (process.env.GOOGLE_OAUTH_CLIENT_SECRET || '').trim(); if (!clientId || !clientSecret) { return { content: [{ type: 'text', text: 'OAuth client credentials not set. Set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET to enable token refresh.' }] }; } try { validateSessionKey(String(input?.session_key || '')); } catch (e: any) { return { content: [{ type: 'text', text: `Error: ${e?.message || String(e)}` }] }; } try { const updated = await refreshAccessTokenForSession(String(input.session_key)); const masked = updated.access_token && updated.access_token.length > 8 ? `${updated.access_token.slice(0,4)}****${updated.access_token.slice(-4)}` : '****'; const expiresIn = Math.max(0, Math.floor(((updated.expires_at || (Date.now()+3600000)) - Date.now())/1000)); return { content: [{ type: 'text', text: JSON.stringify({ status: 'refreshed', expires_in: expiresIn, masked_token: masked }) }] }; } catch (e: any) { const msg = String(e?.message || e); if (msg.includes('invalid_grant')) { return { content: [{ type: 'text', text: JSON.stringify({ error: { code: 'ERR_INVALID_GRANT', message: 'Refresh token invalid or revoked. Re-authentication required.' } }) }] }; } return { content: [{ type: 'text', text: JSON.stringify({ error: { code: 'ERR_REFRESH_FAILED', message: msg } }) }] }; } } ); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/martechery/mcp-google-ads-ts'

If you have feedback or need assistance with the MCP directory API, please join our Discord server