server-tools.ts•46.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 } }) }] };
}
}
);
}