Skip to main content
Glama
martechery

Google Ads MCP Server

by martechery

execute_gaql_query

Run GAQL queries to retrieve Google Ads data for campaigns, metrics, and account management with support for pagination and multiple output formats.

Instructions

Execute GAQL. Optional: login_customer_id (aka MCC/manager account id) overrides env.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
customer_idNo10-digit customer ID (no dashes). Optional.
login_customer_idNoManager account (MCC) ID to use as login-customer for this request (10 digits, no dashes). Overrides env GOOGLE_ADS_MANAGER_ACCOUNT_ID.
queryYesGAQL query string. Examples: SELECT campaign.id, campaign.name, metrics.clicks FROM campaign WHERE segments.date DURING LAST_30_DAYS LIMIT 10 SELECT ad_group_ad.ad.id, metrics.impressions FROM ad_group_ad WHERE campaign.status = 'ENABLED' LIMIT 50
page_sizeNooptional page size (1-10000)
page_tokenNooptional page token
auto_paginateNofetch multiple pages automatically
max_pagesNolimit when auto_paginate=true (1-20)
output_formatNorender formattable

Implementation Reference

  • Primary MCP tool handler for 'execute_gaql_query'. Manages sessions, rate limiting, customer selection, pagination (auto or manual), error mapping, logging, and output formatting (table/json/csv). Delegates the actual GAQL API call to the executeGaql helper.
        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;
      }
    );
  • Zod schema (ExecuteGaqlZ) defining input parameters and validation for the execute_gaql_query tool, including descriptions for LLM guidance.
    export const ExecuteGaqlZ = z.object({
      customer_id: z.string().optional().describe('10-digit customer ID (no dashes). Optional.'),
      // Per-call login customer (MCC/manager) override
      login_customer_id: z.union([z.string(), z.number()]).optional().describe('Manager account (MCC) ID to use as login-customer for this request (10 digits, no dashes). Overrides env GOOGLE_ADS_MANAGER_ACCOUNT_ID.'),
    
      query: z.string().describe(
        [
          'GAQL query string. Examples:',
          "SELECT campaign.id, campaign.name, metrics.clicks FROM campaign WHERE segments.date DURING LAST_30_DAYS LIMIT 10",
          "SELECT ad_group_ad.ad.id, metrics.impressions FROM ad_group_ad WHERE campaign.status = 'ENABLED' LIMIT 50",
        ].join('\n')
      ),
      page_size: z.number().min(1).optional().describe('optional page size (1-10000)'),
      page_token: z.string().optional().describe('optional page token'),
      auto_paginate: z.boolean().default(false).describe('fetch multiple pages automatically'),
      max_pages: z.number().min(1).max(20).default(5).describe('limit when auto_paginate=true (1-20)'),
      output_format: z.enum(['table','json','csv']).default('table').describe('render format'),
    });
    export const ExecuteGaqlSchema: JsonSchema = zodToJsonSchema(ExecuteGaqlZ, 'ExecuteGaql') as unknown as JsonSchema;
  • Core helper function executeGaql that performs the HTTP POST to Google Ads API /googleAds:search endpoint, handling auth headers, pagination params, and response parsing.
    export async function executeGaql({ customerId, query, pageSize, pageToken, loginCustomerId, sessionKey }: ExecuteGaqlParams): Promise<GaqlResponse> {
      const { token, quotaProjectId, developerToken: devFromToken } = await getAccessToken(sessionKey);
      const developerToken = devFromToken || process.env.GOOGLE_ADS_DEVELOPER_TOKEN || '';
      const loginCustomerHeader = (loginCustomerId != null ? String(loginCustomerId) : process.env.GOOGLE_ADS_MANAGER_ACCOUNT_ID);
    
      const headers = buildAdsHeaders({
        accessToken: token,
        developerToken,
        loginCustomerId: loginCustomerHeader,
        quotaProjectId,
      });
    
      const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${formatCustomerId(customerId)}/googleAds:search`;
      const body: any = { query };
      if (typeof pageSize === 'number' && pageSize > 0) body.pageSize = pageSize;
      if (pageToken) body.pageToken = pageToken;
    
      const init: RequestInit = {
        method: 'POST',
        headers,
        body: JSON.stringify(body),
      };
      const res = await fetch(url, init);
    
      const contentType = res.headers?.get?.('content-type') || '';
      if (res.ok) {
        const data = contentType.includes('application/json') ? await res.json() : await res.text();
        return { ok: true, status: res.status, data };
      }
      const errorText = await res.text();
      return { ok: false, status: res.status, errorText };
    }
  • Tool registration via addTool in registerTools function, specifying name, description, input schema, and handler.
      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;
      }
    );
  • src/server.ts:13-13 (registration)
    Invocation of registerTools to register all tools including execute_gaql_query on the MCP server instance.
    registerTools(server as any);
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden of behavioral disclosure. It mentions the login_customer_id override behavior but fails to describe critical aspects like whether this is a read-only or write operation, authentication requirements, rate limits, error handling, or what the tool returns. For a query execution tool with 8 parameters, this is inadequate.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is extremely concise with just one sentence that directly addresses the tool's core functionality and one important behavioral note. Every word earns its place with zero wasted text, though this conciseness comes at the cost of completeness.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a complex query execution tool with 8 parameters, no annotations, and no output schema, the description is severely incomplete. It doesn't explain what GAQL is, what data can be queried, authentication requirements, expected return format, error conditions, or how results are structured. The agent would need to rely heavily on the schema and external knowledge.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so the schema already documents all parameters thoroughly. The description adds minimal value by mentioning the login_customer_id override behavior, but doesn't provide additional context about parameter interactions, query syntax beyond what's in the schema examples, or practical usage patterns.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool executes GAQL queries, which is a specific action with a defined resource (Google Ads Query Language). It distinguishes from siblings like 'gaql_help' (which likely provides assistance) and 'get_performance' (which may retrieve specific metrics). However, it doesn't explicitly differentiate from 'list_resources' which might also query data.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description provides minimal guidance, only mentioning that login_customer_id overrides environment variables. It doesn't explain when to use this tool versus alternatives like 'list_resources' or 'get_performance', nor does it provide context about prerequisites, query limitations, or appropriate use cases.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

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