From ede14f89fba3db8b93b5d41b72b482968baecb5d Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Wed, 6 May 2026 14:32:27 +0530 Subject: [PATCH 1/8] planning documentation for mcp --- LOCAL_DEVELOPMENT.md | 274 ++++++++++++++ MCP_CHANGE_PLAN.md | 846 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1120 insertions(+) create mode 100644 LOCAL_DEVELOPMENT.md create mode 100644 MCP_CHANGE_PLAN.md diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md new file mode 100644 index 0000000..a01723c --- /dev/null +++ b/LOCAL_DEVELOPMENT.md @@ -0,0 +1,274 @@ +# Local Development Setup + +This guide documents the exact local setup used to run this repository from source on macOS with `zsh`, including the helper scripts and gitignored files added in this workspace. + +## What Was Added + +- `.venv/`: local Python virtual environment +- `.env.local.example`: template for local environment variables +- `.env.local`: local env file used only on this machine +- `run-local.sh`: starts the MCP server using values from `.env.local` +- `gcloud-local.sh`: runs `gcloud` with a repo-local config directory +- `.gcloud/`: repo-local Google Cloud CLI config and ADC credentials +- `.gitignore` entries for `.env.local` and `.gcloud/` + +## Why This Setup Exists + +This repo requires Python `>=3.10`, but the default system Python on this machine was older. The setup below avoids changing the project code and keeps credentials out of tracked files. + +The main goals were: + +- use a compatible Python version +- keep dependencies isolated in a local virtualenv +- avoid storing Google auth state in the global `~/.config/gcloud` +- make startup a one-command flow + +## Prerequisites + +- Homebrew +- Python 3.11 +- Google Cloud CLI +- A Google Cloud project with the [Google Ads API enabled](https://console.cloud.google.com/apis/library/googleads.googleapis.com) +- A Google Ads [Developer Token](https://developers.google.com/google-ads/api/docs/get-started/dev-token) +- An OAuth client JSON for Application Default Credentials, if you are using the ADC flow from the repo README + +## Step 1: Install Python 3.11 + +Python 3.11 was installed with Homebrew: + +```bash +brew install python@3.11 +``` + +The installed interpreter path is: + +```bash +/opt/homebrew/bin/python3.11 +``` + +## Step 2: Create a Local Virtual Environment + +From the repo root: + +```bash +/opt/homebrew/bin/python3.11 -m venv .venv +``` + +Then install the project into that environment: + +```bash +./.venv/bin/pip install -e . +``` + +This installs the local entrypoint used by the repo: + +```bash +./.venv/bin/google-ads-mcp +``` + +## Step 3: Verify the Local Install + +Two lightweight checks were used: + +```bash +./.venv/bin/python -c "import ads_mcp.server; print('server import ok')" +./.venv/bin/python -m unittest tests.server_test +``` + +These confirm the package imports correctly and the basic server initialization test passes. + +## Step 4: Install Google Cloud CLI + +The Google Cloud CLI was installed with Homebrew: + +```bash +brew install --cask google-cloud-sdk +``` + +The main executable path is: + +```bash +/opt/homebrew/bin/gcloud +``` + +## Step 5: Use a Repo-Local `gcloud` Config + +Instead of writing Google auth state to the global config directory, this setup uses `gcloud-local.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +export CLOUDSDK_PYTHON="/opt/homebrew/bin/python3.11" +export CLOUDSDK_CONFIG="${ROOT_DIR}/.gcloud" + +exec /opt/homebrew/bin/gcloud "$@" +``` + +This does two things: + +- forces `gcloud` to use Python 3.11 +- stores credentials and config in `./.gcloud/` + +Verify it works: + +```bash +./gcloud-local.sh --version +``` + +## Step 6: Authenticate with Application Default Credentials + +The repo README supports ADC. Use the local wrapper so credentials stay inside `.gcloud/`: + +```bash +./gcloud-local.sh auth application-default login \ + --scopes https://www.googleapis.com/auth/adwords,https://www.googleapis.com/auth/cloud-platform \ + --client-id-file /absolute/path/to/your-oauth-client.json +``` + +When this succeeds, the credentials file is typically created in the repo-local config directory. In this setup, the intended path is: + +```bash +/absolute/path/to/repo/.gcloud/application_default_credentials.json +``` + +## Step 7: Configure Environment Variables + +The repo now includes `.env.local.example`: + +```env +GOOGLE_PROJECT_ID=your-gcp-project-id +GOOGLE_ADS_DEVELOPER_TOKEN=your-google-ads-developer-token +GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/application_default_credentials.json +# Optional: if you access the target account through a manager account +# GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 +``` + +Copy it to `.env.local`: + +```bash +cp .env.local.example .env.local +``` + +Then update `.env.local` with real values. In this workspace, `.env.local` was prefilled to expect the repo-local ADC file: + +```env +GOOGLE_PROJECT_ID=your-gcp-project-id +GOOGLE_ADS_DEVELOPER_TOKEN=your-google-ads-developer-token +GOOGLE_APPLICATION_CREDENTIALS=/Users/varunbhayana/Desktop/projects/google-ads-mcp/.gcloud/application_default_credentials.json +# GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 +``` + +## Step 8: Start the Server + +Use the helper script: + +```bash +./run-local.sh +``` + +`run-local.sh` does the following: + +- checks that `.env.local` exists +- loads env vars from `.env.local` +- starts `./.venv/bin/google-ads-mcp` + +The script contents are: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ROOT_DIR}/.env.local" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "Missing ${ENV_FILE}" + echo "Copy .env.local.example to .env.local and fill in your values." + exit 1 +fi + +set -a +source "${ENV_FILE}" +set +a + +exec "${ROOT_DIR}/.venv/bin/google-ads-mcp" +``` + +## Optional: MCP Client Configuration + +If your MCP client can launch a local command directly, point it at the local binary and pass the same env vars. + +Example: + +```json +{ + "mcpServers": { + "google-ads-mcp": { + "command": "/absolute/path/to/google-ads-mcp/.venv/bin/google-ads-mcp", + "env": { + "GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/application_default_credentials.json", + "GOOGLE_PROJECT_ID": "your-gcp-project-id", + "GOOGLE_ADS_DEVELOPER_TOKEN": "your-developer-token" + } + } + } +} +``` + +## Security Notes + +- Do not commit `.env.local`. +- Do not commit `.gcloud/`. +- Do not paste service account keys or OAuth credentials into tracked files. +- If a private key or service account JSON was exposed in chat, logs, or a file, rotate it immediately in Google Cloud IAM. + +## File Reference + +| File | Purpose | +|---|---| +| `LOCAL_DEVELOPMENT.md` | This local runbook | +| `run-local.sh` | Loads `.env.local` and starts the server | +| `gcloud-local.sh` | Runs `gcloud` with repo-local config and Python 3.11 | +| `.env.local.example` | Template for required env vars | +| `.env.local` | Local credentials and config values | +| `.gcloud/` | Repo-local Google Cloud CLI config and ADC state | +| `.venv/` | Local Python virtual environment | + +## Troubleshooting + +**`Missing .env.local`** + +Create it from the template: + +```bash +cp .env.local.example .env.local +``` + +**`GOOGLE_ADS_DEVELOPER_TOKEN environment variable not set`** + +Fill in `GOOGLE_ADS_DEVELOPER_TOKEN` inside `.env.local`. + +**`google-ads-mcp: command not found`** + +Reinstall the editable package: + +```bash +./.venv/bin/pip install -e . +``` + +**`The developer token is only approved for use with test accounts`** + +Your token does not yet have the required production access level. See the [Google Ads access levels documentation](https://developers.google.com/google-ads/api/docs/access-levels). + +**ADC or auth errors** + +Repeat the login flow with the local wrapper: + +```bash +./gcloud-local.sh auth application-default login \ + --scopes https://www.googleapis.com/auth/adwords,https://www.googleapis.com/auth/cloud-platform \ + --client-id-file /absolute/path/to/your-oauth-client.json +``` diff --git a/MCP_CHANGE_PLAN.md b/MCP_CHANGE_PLAN.md new file mode 100644 index 0000000..73c9b34 --- /dev/null +++ b/MCP_CHANGE_PLAN.md @@ -0,0 +1,846 @@ +# MCP Change Plan for Required Google Ads Reporting + +This document explains, step by step, what changes would be needed if you want this MCP server to support only the Google Ads reporting use cases you listed while limiting generic campaign and ad-group reporting that you already have elsewhere. + +No code changes are made by this document. This is a planning and implementation guide only. + +## Goal + +You want the MCP server to support these reporting categories: + +- Impression Share +- Lost IS (Budget) +- Lost IS (Rank) +- Quality Score +- Ad Rank +- Keyword-level efficiency +- Search Term Report for negative keyword discovery +- Top-of-page rate +- Absolute top-of-page rate +- Audience analysis +- Change history +- Reach & Frequency +- Reach / frequency union and overlap math +- Auction Insights +- Keywords +- Product catalog +- TAM / audience size + +You also want to avoid exposing broad generic reporting such as campaign or ad-group daily/hourly insights because that already exists in your current system. + +## Current State of This MCP Server + +The current MCP server is generic and thin. It mainly exposes: + +- `search` +- `get_resource_metadata` +- `list_accessible_customers` + +Relevant files: + +- `ads_mcp/tools/search.py` +- `ads_mcp/tools/get_resource_metadata.py` +- `ads_mcp/tools/core.py` +- `ads_mcp/server.py` +- `ads_mcp/gaql_resources.txt` + +This means the server currently behaves as a general Google Ads GAQL query layer, not a curated business-report API. + +## How Your Repo Will Be Configured with MCP + +This is an important distinction: your repo does not become "MCP-enabled" only by editing application code. The MCP integration has two layers: + +- the MCP server setup +- the MCP client configuration + +### Layer 1: MCP server setup inside or alongside the repo + +This repository already acts as the MCP server codebase. + +For local usage, the server is run from this repo using: + +- `.venv` for Python dependencies +- `.env.local` for credentials and runtime variables +- `run-local.sh` to start the server +- `gcloud-local.sh` to manage repo-local Google auth state + +In practical terms, this means your repo is configured to host the MCP server process itself. + +### Layer 2: MCP client configuration outside the repo + +Your MCP client is what actually connects to the server. This is usually configured outside the repo in a client settings file such as: + +- `~/.claude/settings.json` +- `~/.gemini/settings.json` +- `.cursor/mcp.json` +- `.vscode/mcp.json` + +That config points the client to this repo's local server command or HTTP endpoint. + +Example local command-based configuration: + +```json +{ + "mcpServers": { + "google-ads-mcp": { + "command": "/absolute/path/to/google-ads-mcp/.venv/bin/google-ads-mcp", + "env": { + "GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/google-ads-mcp/.gcloud/application_default_credentials.json", + "GOOGLE_PROJECT_ID": "your-gcp-project-id", + "GOOGLE_ADS_DEVELOPER_TOKEN": "your-developer-token" + } + } + } +} +``` + +### What this means for your main application repo + +If your separate application repo needs to "use MCP," the usual flow is: + +1. keep Google Ads access logic inside this MCP server +2. configure the MCP client used by developers or agents +3. let your application workflows call approved MCP tools instead of directly querying Google Ads everywhere + +This can be done in two ways: + +### Option A: MCP is used only by developers or AI agents + +In this model: + +- your application code does not change much at first +- your MCP-enabled tooling connects to this Google Ads MCP server +- developers or agents use the server for reporting and analysis tasks + +This is the fastest and lowest-risk rollout. + +### Option B: Your application stack uses MCP as an internal service layer + +In this model: + +- your app or orchestration layer calls MCP tools as part of product workflows +- the MCP server becomes a controlled Google Ads data access layer +- custom tools can be added for approved business use cases + +This is the better long-term architecture if you want controlled, reusable reporting access. + +### Recommended configuration flow + +If you move ahead, the repo-level configuration flow should be: + +1. keep the MCP server code and local setup in this repo +2. keep secrets local and out of git +3. add MCP client config in the consuming environment, not in committed secret-bearing files +4. document which tools and report categories are allowed +5. only then decide whether your main application repo should call generic search or custom business-specific MCP tools + +### What should be committed vs local-only + +Safe to commit: + +- documentation +- helper scripts +- example env templates +- ignore rules +- future custom tool code + +Do not commit: + +- `.env.local` +- `.gcloud/` +- `.venv/` +- real credentials or tokens + +### Bottom line + +Your repo will be configured with MCP through: + +- local server setup in this repo +- external MCP client configuration that points to this repo's server +- optional future integration from your main application workflows into approved MCP tools + +So the first configuration step is operational, not business-logic code changes. + +## Chosen Access Model + +The intended design model for this MCP server is: + +- one credential +- multiple clients' data + +More precisely: + +- one shared Google credential authenticates the MCP server +- the MCP client passes `customer_id` dynamically per request +- the server returns data for whichever Google Ads client account that credential is authorized to access +- if access is through a manager account, `login_customer_id` provides the manager context + +This means the access pattern is: + +- shared authentication identity +- dynamic target account selection +- multiple client accounts behind one server + +### Practical interpretation + +In this model: + +- `GOOGLE_APPLICATION_CREDENTIALS` identifies who is calling Google +- `GOOGLE_ADS_LOGIN_CUSTOMER_ID` identifies the manager account context, when needed +- `customer_id` identifies the client account whose data is being queried + +### Important implication + +Because one credential can potentially access multiple client accounts, this model should eventually include MCP-side restrictions such as: + +- an allowlist of approved `customer_id`s +- optional filtering of `list_accessible_customers` +- query-level policy checks on allowed report categories + +Without those restrictions, the MCP server may expose any account reachable by the shared credential. + +## Detailed Flow Diagram + +The following diagrams show how a request moves from an MCP client to Google Ads, where `customer_id` and `login_customer_id` are used, and where future restriction logic would sit. + +### 1. High-Level MCP Flow + +```text ++-------------------+ +| MCP Client | +| Claude/Cursor/etc | ++---------+---------+ + | + | tool call + | search(customer_id, resource, fields, ...) + v ++---------+---------+ +| MCP Server | +| google-ads-mcp | ++---------+---------+ + | + | validates request + | applies future policy rules + v ++---------+---------+ +| Google Ads Client | +| built from ADC | ++---------+---------+ + | + | Google Ads API request + v ++---------+---------+ +| Google Ads API | ++---------+---------+ + | + | GAQL results + v ++---------+---------+ +| MCP Server | +| formats response | ++---------+---------+ + | + | MCP tool response + v ++-------------------+ +| MCP Client | ++-------------------+ +``` + +### 2. Account and Credential Flow + +```text ++--------------------------------------------------------------+ +| Local runtime config | +| .env.local | +| - GOOGLE_APPLICATION_CREDENTIALS | +| - GOOGLE_ADS_LOGIN_CUSTOMER_ID (optional, usually MCC) | ++------------------------------+-------------------------------+ + | + v ++--------------------------------------------------------------+ +| ADC credentials | +| application_default_credentials.json | +| "Who is calling Google?" | ++------------------------------+-------------------------------+ + | + v ++--------------------------------------------------------------+ +| MCP server request context | +| - login_customer_id from env | +| - customer_id passed dynamically by MCP client | ++------------------------------+-------------------------------+ + | + v ++--------------------------------------------------------------+ +| Google Ads request meaning | +| login_customer_id = manager / access context | +| customer_id = target ads account whose data is queried | ++--------------------------------------------------------------+ +``` + +### 3. Recommended Restriction Flow + +```text ++--------------------+ +| MCP Client | +| passes customer_id | ++---------+----------+ + | + v ++---------+----------+ +| search tool | +| ads_mcp/tools/ | +| search.py | ++---------+----------+ + | + | Step A: validate customer_id + | - allowed account? + | - approved child under MCC? + | + | Step B: validate query shape + | - blocked resource? + | - blocked segment? + | - blocked combination? + v ++---------+----------+ +| allowed? | ++----+-----------+---+ + | | + no| |yes + | | + v v ++----+----+ +--+------------------+ +| return | | execute Google Ads | +| MCP | | API request | +| error | +--+------------------+ ++---------+ | + v + +-----+------------------+ + | format and return data | + +------------------------+ +``` + +### 4. Future Curated Tool Flow + +If you later add custom business-specific tools, the flow becomes more controlled: + +```text ++----------------------+ +| MCP Client | +| calls approved tool | +| e.g. search term | ++----------+-----------+ + | + v ++----------+-----------+ +| Custom MCP Tool | +| ads_mcp/tools/... | ++----------+-----------+ + | + | fixed resource mapping + | fixed allowed metrics + | fixed allowed segments + v ++----------+-----------+ +| internal GAQL query | ++----------+-----------+ + | + v ++----------+-----------+ +| Google Ads API | ++----------+-----------+ + | + v ++----------------------+ +| standard response | ++----------------------+ +``` + +### 5. End-to-End Decision Flow + +This is the most practical way to think about the final design: + +```text +1. Client authenticates to MCP environment +2. Client calls MCP tool +3. MCP server receives dynamic customer_id +4. MCP server applies account-level restrictions +5. MCP server applies report/query-level restrictions +6. If blocked, return MCP error +7. If allowed, build Google Ads request +8. Use ADC credentials for authentication +9. Use optional login_customer_id as manager context +10. Query target customer_id +11. Format results +12. Return approved data to client +``` + +### Where Each Concern Lives + +```text +Authentication identity + -> GOOGLE_APPLICATION_CREDENTIALS + +Manager access context + -> GOOGLE_ADS_LOGIN_CUSTOMER_ID + +Dynamic target account + -> customer_id passed to search/custom tool + +Account allowlisting + -> future validation layer in search.py / tool wrappers + +Report-type restrictions + -> future validation layer in search.py / tool wrappers + +Approved business outputs + -> future custom tools in ads_mcp/tools/ +``` + +## What Is Already Available Today + +These items are likely already available through the existing `search` tool, assuming the required Google Ads fields and metrics are supported by GAQL for the chosen resource: + +- Impression Share +- Lost IS (Budget) +- Lost IS (Rank) +- Quality Score +- Keyword-level efficiency +- Search Term Report for negative keyword discovery +- Top-of-page rate +- Absolute top-of-page rate +- Audience analysis +- Change history +- Keywords +- Product catalog / shopping-style product reporting + +These are likely partial or need validation: + +- Reach & Frequency +- TAM / audience size + +These are not clean first-class capabilities in the current MCP server and would likely need custom handling or may not be available directly: + +- Ad Rank +- Reach / frequency union and overlap math +- Auction Insights + +## Likely Resource Mapping + +These are the main Google Ads resource families you would likely use for each need. + +### Impression Share, Lost IS (Budget), Lost IS (Rank) + +Potential resources: + +- `campaign` +- `ad_group` +- `keyword_view` +- `ad_group_criterion` + +### Quality Score and keyword-level efficiency + +Potential resources: + +- `keyword_view` +- `ad_group_criterion` + +### Search Term Report + +Potential resources: + +- `search_term_view` +- `campaign_search_term_view` +- `customer_search_term_insight` +- `dynamic_search_ads_search_term_view` +- `smart_campaign_search_term_view` + +### Top-of-page and absolute top-of-page rate + +Potential resources: + +- `keyword_view` +- `ad_group_criterion` +- `campaign` +- `ad_group` + +### Audience analysis + +Potential resources: + +- `campaign_audience_view` +- `ad_group_audience_view` +- `audience` +- `combined_audience` +- `custom_audience` +- `user_list` + +### Change history + +Potential resource: + +- `change_event` + +### Keywords + +Potential resources: + +- `keyword_view` +- `ad_group_criterion` +- `display_keyword_view` + +### Product catalog + +Potential resources: + +- `shopping_product` +- `shopping_performance_view` +- `product_group_view` +- `asset_group_product_group_view` + +## What Should Be Limited + +Since you already have campaign and ad-group daily/hourly reporting elsewhere, the MCP server should eventually avoid exposing those generic analytics patterns. + +The main things to limit later are: + +- generic `campaign` reporting +- generic `ad_group` reporting +- daily segmented reporting using `segments.date` +- hourly segmented reporting using `segments.hour` +- day-of-week breakdowns if those overlap with your current system +- broad metric exploration outside your approved use cases + +## Recommended Implementation Strategy + +The cleanest path is not to rely forever on unrestricted GAQL search. Instead, move in phases. + +## Phase 1: Capability Validation + +Before changing behavior, validate exactly which requested categories are truly available via Google Ads GAQL. + +### Step 1 + +Use `get_resource_metadata` on the most relevant resources: + +- `keyword_view` +- `ad_group_criterion` +- `search_term_view` +- `campaign_search_term_view` +- `campaign_audience_view` +- `ad_group_audience_view` +- `change_event` +- `shopping_product` +- `shopping_performance_view` +- `product_group_view` + +Purpose: + +- confirm that required metrics and segments exist +- confirm whether the fields you need are selectable and filterable + +### Step 2 + +Create a business mapping sheet with these columns: + +- requirement +- candidate resource +- candidate metrics +- candidate segments +- status +- notes + +Status should be one of: + +- available now +- available with custom wrapper +- partially available +- not available + +### Step 3 + +Explicitly validate the uncertain cases: + +- Ad Rank +- Auction Insights +- Reach & Frequency +- TAM / audience size +- Reach/frequency overlap math + +These should not be assumed to exist until validated. + +## Phase 2: Define the Allowed Scope + +Once capability validation is done, define what the MCP server is allowed to expose. + +### Step 4 + +Create an allowlist of approved reporting categories: + +- keyword insights +- search term analysis +- audience analysis +- change history +- product and shopping reporting +- impression-share-style metrics + +### Step 5 + +Create a denylist of broad reporting categories that should not be exposed: + +- generic campaign reporting +- generic ad-group reporting +- daily campaign insights +- hourly campaign insights +- daily ad-group insights +- hourly ad-group insights + +### Step 6 + +Define whether the denylist should block: + +- resources +- segments +- metrics +- or combinations of all three + +In practice, combination-based blocking is usually best. For example: + +- allow `campaign` only for impression-share-related queries +- block `campaign` when combined with `segments.date` +- block `ad_group` when combined with `segments.hour` + +## Phase 3: Decide the Product Shape + +You have two design options. + +### Option A: Restrict the generic `search` tool + +This keeps the current architecture but adds safety rules. + +What would change: + +- parse inputs to the `search` tool +- reject disallowed resources +- reject disallowed segments +- reject disallowed combinations +- return a clear MCP error message when a blocked query is attempted + +Best file for this: + +- `ads_mcp/tools/search.py` + +### Option B: Add custom purpose-built tools + +This is the cleaner long-term design if you want predictable outputs for downstream agents or application workflows. + +Examples of future tools: + +- `get_impression_share_report` +- `get_keyword_efficiency_report` +- `get_search_term_discovery_report` +- `get_audience_analysis_report` +- `get_change_history_report` +- `get_product_catalog_report` + +What would change: + +- add new files under `ads_mcp/tools/` +- register them via imports in `ads_mcp/server.py` +- optionally reduce or hide generic search from some environments + +Recommended approach: + +- use both +- keep `search` but restrict it +- add custom tools for the highest-value business reports + +## Phase 4: Implement Restrictions + +If you choose to limit generic access, these are the step-by-step code changes. + +### Step 7 + +Add a policy layer in `ads_mcp/tools/search.py`. + +The policy should inspect: + +- `resource` +- `fields` +- `conditions` +- `orderings` + +It should decide whether the query is: + +- allowed +- blocked +- allowed only for specific metric patterns + +### Step 8 + +Create explicit validation rules such as: + +- block `campaign` with `segments.date` +- block `campaign` with `segments.hour` +- block `ad_group` with `segments.date` +- block `ad_group` with `segments.hour` +- allow `keyword_view` for quality score and efficiency metrics +- allow `search_term_view` for discovery use cases +- allow `change_event` +- allow audience view resources +- allow shopping/product resources + +### Step 9 + +Add human-readable error messages when a query is blocked. + +Examples: + +- "Generic campaign daily insights are not available through this MCP server." +- "Use the keyword or search term reporting tools for this use case." + +### Step 10 + +Optionally restrict metadata discovery in `ads_mcp/tools/get_resource_metadata.py` so agents cannot freely discover resources you intend to block. + +Without this step, an agent may still see blocked resources even if it cannot query them. + +## Phase 5: Add Custom Reporting Tools + +If you decide to create a more curated interface, use these steps. + +### Step 11 + +Create one tool per business capability group instead of one tool per metric. + +Suggested grouping: + +- keyword and competitiveness insights +- search term discovery +- audience analysis +- change history +- product and catalog reporting + +### Step 12 + +For each tool, define: + +- required inputs +- allowed segments +- allowed filters +- default date range behavior +- output shape + +Example for search term discovery: + +- input: `customer_id`, date range, campaign filter, ad group filter +- output: search terms, impressions, clicks, cost, conversions, CTR, match context +- guardrail: no unrelated generic campaign reporting + +### Step 13 + +Register these new tools in `ads_mcp/server.py`. + +## Phase 6: Handle the Uncertain or Advanced Requests + +These need special treatment. + +### Ad Rank + +Treat this as unconfirmed until proven available from Google Ads fields you can access through GAQL. If not directly available, document it as unsupported. + +### Auction Insights + +Treat this as unconfirmed until validated. If Google Ads does not expose it in the form you need through GAQL, it should be marked unsupported in the MCP server. + +### Reach & Frequency + +Validate first. If partially available, it may need a dedicated wrapper tool that standardizes the available fields and explains limitations. + +### Reach / frequency union and overlap math + +This is likely not a raw Google Ads resource output. It probably belongs in: + +- a custom analytics layer +- or a custom MCP tool that combines multiple source queries and computes derived results + +### TAM / audience size + +This may depend on your exact meaning of TAM. If it maps to available audience-size-style data, it may be partially supported. If it requires planning or forecasting semantics, it may need a dedicated derived tool. + +## Phase 7: Testing Plan + +Once implementation starts, validate behavior in layers. + +### Step 14 + +Add unit tests for any query restriction logic. + +Likely test file location: + +- `tests/tools/` + +### Step 15 + +Add tool-level tests for each custom reporting tool that is introduced. + +### Step 16 + +Add smoke tests for: + +- allowed keyword reports +- allowed search term reports +- allowed audience reports +- blocked campaign daily reports +- blocked ad-group hourly reports + +## Suggested Deliverables + +If you move ahead, the implementation should probably produce: + +### Deliverable 1 + +A capability matrix that says: + +- requirement +- supported now +- supported after wrapper +- unsupported + +### Deliverable 2 + +A restriction policy for generic search. + +### Deliverable 3 + +Custom MCP tools for the highest-priority approved use cases. + +### Deliverable 4 + +Tests covering both allowed and blocked scenarios. + +## Recommended Order of Work + +If you want the safest rollout, do the work in this order: + +1. Validate exact field support using `get_resource_metadata` +2. Create the capability matrix +3. Finalize the allowlist and denylist +4. Restrict generic `search` +5. Add custom tools for approved report families +6. Add tests +7. Update docs for client usage + +## Final Recommendation + +Do not start by building everything as one open-ended search surface. + +A better approach is: + +- validate what Google Ads truly supports +- restrict generic campaign/ad-group daily/hourly exploration +- keep keyword, search term, audience, change history, and product reporting +- add custom tools for the business flows you care about most + +That gives you a more controlled MCP server and avoids overlapping with analytics you already have. From 3e059a884036cfca3351ca99cf4f423a987e9e35 Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Wed, 6 May 2026 15:59:58 +0530 Subject: [PATCH 2/8] Updated --- MCP_CAPABILITIES.md | 284 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 MCP_CAPABILITIES.md diff --git a/MCP_CAPABILITIES.md b/MCP_CAPABILITIES.md new file mode 100644 index 0000000..0dfe916 --- /dev/null +++ b/MCP_CAPABILITIES.md @@ -0,0 +1,284 @@ +# MCP Capabilities for Requested Google Ads Data + +This document summarizes what is currently available from this repository's MCP server without changing its behavior. + +## Important Context + +This server does not expose a separate MCP tool for each business report. It mainly exposes: + +- `search`: run Google Ads GAQL queries +- `get_resource_metadata`: discover fields, metrics, and segments allowed for a resource +- `list_accessible_customers`: list available customer IDs + +That means most reporting needs are supported only if the underlying Google Ads API resource and metrics are queryable through GAQL. + +## Current MCP Tools + +The current MCP tools are defined in: + +- `ads_mcp/tools/search.py` +- `ads_mcp/tools/get_resource_metadata.py` +- `ads_mcp/tools/core.py` + +The server registers them through: + +- `ads_mcp/server.py` + +## Requested Items: Availability Summary + +### Likely available now through the existing `search` tool + +- Impression Share +- Lost IS (Budget) +- Lost IS (Rank) +- Quality Score +- Keyword-level efficiency +- Search Term Report for negative keyword discovery +- Top-of-page rate +- Absolute top-of-page rate +- Audience analysis +- Change history +- Keywords +- Product catalog / shopping-related product reporting + +### Likely partial, derived, or dependent on exact Google Ads field support + +- Reach & Frequency +- TAM / audience size + +### Likely not directly available as a clean first-class output today + +- Ad Rank +- Reach / frequency union & overlap math +- Auction Insights + +## Why These Are the Answers + +The server is backed by GAQL resources listed in: + +- `ads_mcp/gaql_resources.txt` + +That file already includes relevant resource families such as: + +- `keyword_view` +- `ad_group_criterion` +- `search_term_view` +- `campaign_search_term_view` +- `customer_search_term_insight` +- `change_event` +- `audience` +- `ad_group_audience_view` +- `campaign_audience_view` +- `user_list` +- `shopping_product` +- `shopping_performance_view` +- `product_group_view` +- `asset_group_product_group_view` + +Because these resources already exist in the MCP server's valid-resource list, they are the strongest indicators that the current `search` tool can be used for those categories. + +## Mapping Your Requirements to Likely Resources + +### Impression Share, Lost IS (Budget), Lost IS (Rank) + +Likely queryable through campaign-, ad-group-, or keyword-related GAQL resources using compatible `metrics.*` fields. + +Potential resource families: + +- `campaign` +- `ad_group` +- `keyword_view` +- `ad_group_criterion` + +### Quality Score and keyword-level efficiency + +Likely queryable from keyword-related resources. + +Potential resource families: + +- `keyword_view` +- `ad_group_criterion` + +Typical efficiency metrics would also come from compatible `metrics.*` fields such as clicks, cost, CTR, conversions, CPC, and related measures. + +### Search Term Report for negative keyword discovery + +Strong fit for the current MCP server. + +Potential resource families: + +- `search_term_view` +- `campaign_search_term_view` +- `customer_search_term_insight` +- `dynamic_search_ads_search_term_view` +- `smart_campaign_search_term_view` + +### Top-of-page / Abs-top-of-page rate + +Likely available through keyword or campaign reporting resources where those metrics are compatible. + +Potential resource families: + +- `keyword_view` +- `ad_group_criterion` +- `campaign` +- `ad_group` + +### Audience analysis + +Likely available now through audience-oriented resources. + +Potential resource families: + +- `campaign_audience_view` +- `ad_group_audience_view` +- `audience` +- `combined_audience` +- `custom_audience` +- `user_list` + +### Change history + +Strong fit for the current MCP server. + +Potential resource family: + +- `change_event` + +### Reach & Frequency + +Possibly partial depending on which fields Google Ads exposes through GAQL for the relevant resources. This likely needs validation against `get_resource_metadata` for the exact resource you want to use. + +### Reach / frequency union & overlap math + +Not likely to be a built-in Google Ads API report exposed directly by this MCP server. This sounds more like downstream analysis that would need: + +- multiple source queries +- custom aggregation logic +- possibly a custom MCP tool or external analytics layer + +### Auction Insights + +Not currently represented as a dedicated MCP tool in this repository. If Google Ads exposes some related data indirectly, it is not modeled here as an opinionated report today. + +### Keywords + +Strong fit for the current MCP server. + +Potential resource families: + +- `keyword_view` +- `ad_group_criterion` +- `display_keyword_view` + +### Product catalog + +Likely available for shopping / product reporting use cases. + +Potential resource families: + +- `shopping_product` +- `shopping_performance_view` +- `product_group_view` +- `asset_group_product_group_view` + +### TAM / audience size + +This may be partially available depending on whether your intended notion of TAM maps to Google Ads audience resources and fields. It is not currently represented as a dedicated, ready-made MCP output in this repository. + +## Generic Reporting That Is Also Broadly Available + +Because this MCP server exposes a generic GAQL `search` tool, it can potentially query much more than the items you listed, including: + +- campaign reporting +- ad group reporting +- ad reporting +- asset reporting +- keyword reporting +- audience reporting +- placement reporting +- landing page reporting +- geographic reporting +- device reporting +- demographic reporting +- shopping and product reporting +- recommendations +- bidding entities +- conversion-related entities +- experiments and drafts + +This is why limiting scope matters if you only want a subset of reporting exposed. + +## If You Want to Limit What the MCP Server Can Return Later + +No changes have been made for this yet. If you decide to restrict behavior later, the main places would be: + +### Primary enforcement point + +- `ads_mcp/tools/search.py` + +This is the best place to: + +- block certain resources +- block certain segments such as date/hour/day breakdowns +- block resource and metric combinations +- enforce an allowlist of approved reporting categories + +### Metadata visibility control + +- `ads_mcp/tools/get_resource_metadata.py` + +This is the place to limit what field metadata the MCP server reveals if you do not want agents discovering blocked resources or blocked fields. + +### Opinionated custom tools + +If you later want fixed-purpose tools such as: + +- `get_keyword_insights` +- `get_search_term_report` +- `get_audience_analysis` +- `get_change_history` + +those would be added under: + +- `ads_mcp/tools/` + +and imported from: + +- `ads_mcp/server.py` + +## Recommended Future Shape + +If your goal is controlled exposure instead of open-ended GAQL querying, a good future structure would be: + +- allow keyword/search-term/audience/change-history/product use cases +- block generic campaign and ad-group daily/hourly reporting if you already have that elsewhere +- expose a small number of custom MCP tools for the approved business reports + +That would make the server safer and easier for downstream agents to use consistently. + +## Bottom Line + +### Available now + +- most keyword, search term, audience, change history, and product-related reporting +- impression share and related competitive delivery metrics, if exposed as compatible Google Ads metrics on supported resources + +### Partially available or needs validation + +- reach and frequency +- TAM / audience size + +### Not cleanly available as a ready-made MCP capability today + +- ad rank +- auction insights +- reach/frequency union and overlap math + +## Reference Files + +- `ads_mcp/tools/search.py` +- `ads_mcp/tools/get_resource_metadata.py` +- `ads_mcp/tools/core.py` +- `ads_mcp/server.py` +- `ads_mcp/gaql_resources.txt` From e131c34e8ae3cc8eeba7e023c569d32b12a63fbf Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Thu, 7 May 2026 13:30:48 +0530 Subject: [PATCH 3/8] in progress --- .env.docker | 4 + .env.local | 6 + .gitignore | 10 +- .mcp.json | 12 + LOCAL_DEVELOPMENT.md | 298 ++++------ MCP_CHANGE_PLAN.md | 890 +++------------------------- ads_mcp/server.py | 6 +- ads_mcp/tools/search_term_report.py | 112 ++++ docker-compose.local.yml | 14 + 9 files changed, 386 insertions(+), 966 deletions(-) create mode 100644 .env.docker create mode 100644 .env.local create mode 100644 .mcp.json create mode 100644 ads_mcp/tools/search_term_report.py create mode 100644 docker-compose.local.yml diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..bb06d97 --- /dev/null +++ b/.env.docker @@ -0,0 +1,4 @@ +GOOGLE_ADS_DEVELOPER_TOKEN= +GOOGLE_ADS_LOGIN_CUSTOMER_ID= +MCP_LOCAL_HTTP=true +PORT=8080 \ No newline at end of file diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..87cbe54 --- /dev/null +++ b/.env.local @@ -0,0 +1,6 @@ +GOOGLE_PROJECT_ID=your-gcp-project-id +GOOGLE_ADS_DEVELOPER_TOKEN=mM23ZIOAiOldJD-oEItRGw +GOOGLE_APPLICATION_CREDENTIALS=/Users/varunbhayana/Desktop/projects/google-ads-mcp/.gcloud/application_default_credentials.json +# Optional: if you access the target account through a manager account +GOOGLE_ADS_LOGIN_CUSTOMER_ID=506-112-2756 + diff --git a/.gitignore b/.gitignore index 6cab73a..1156133 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,12 @@ coverage.xml *.cover .hypothesis/ .pytest_cache/ -.nox/ \ No newline at end of file +.nox/ +.venv + +# Local credentials and config — never commit +.env.local +.env.docker +.gcloud/ +.mcp.json +pyvenv.cfg \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..45ef1fa --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "google-ads-mcp": { + "command": "/Users/varunbhayana/Desktop/projects/mcpforked/google-ads-mcp/.venv/bin/google-ads-mcp", + "env": { + "GOOGLE_APPLICATION_CREDENTIALS": "/Users/varunbhayana/Desktop/projects/google-ads-mcp/.gcloud/application_default_credentials.json", + "GOOGLE_ADS_DEVELOPER_TOKEN": "mM23ZIOAiOldJD-oEItRGw", + "GOOGLE_ADS_LOGIN_CUSTOMER_ID": "5061122756" + } + } + } +} diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index a01723c..9503ef9 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -1,52 +1,33 @@ # Local Development Setup -This guide documents the exact local setup used to run this repository from source on macOS with `zsh`, including the helper scripts and gitignored files added in this workspace. +This guide documents the exact local setup to run this repository from source on macOS with `zsh`. -## What Was Added +## What Lives in This Repo (Not Committed) -- `.venv/`: local Python virtual environment -- `.env.local.example`: template for local environment variables -- `.env.local`: local env file used only on this machine -- `run-local.sh`: starts the MCP server using values from `.env.local` -- `gcloud-local.sh`: runs `gcloud` with a repo-local config directory -- `.gcloud/`: repo-local Google Cloud CLI config and ADC credentials -- `.gitignore` entries for `.env.local` and `.gcloud/` - -## Why This Setup Exists - -This repo requires Python `>=3.10`, but the default system Python on this machine was older. The setup below avoids changing the project code and keeps credentials out of tracked files. - -The main goals were: +| File / Dir | Purpose | +|---|---| +| `.venv/` | Local Python virtual environment | +| `.env.local` | Local credentials and runtime env vars | +| `.env.local.example` | Template — copy this to `.env.local` | +| `run-local.sh` | Starts the MCP server using values from `.env.local` | +| `gcloud-local.sh` | Runs `gcloud` with a repo-local config directory | +| `.gcloud/` | Repo-local Google Cloud CLI config and ADC credentials | +| `.mcp.json` | Claude Code MCP client config (not committed — contains local paths) | -- use a compatible Python version -- keep dependencies isolated in a local virtualenv -- avoid storing Google auth state in the global `~/.config/gcloud` -- make startup a one-command flow +--- ## Prerequisites -- Homebrew -- Python 3.11 -- Google Cloud CLI -- A Google Cloud project with the [Google Ads API enabled](https://console.cloud.google.com/apis/library/googleads.googleapis.com) -- A Google Ads [Developer Token](https://developers.google.com/google-ads/api/docs/get-started/dev-token) -- An OAuth client JSON for Application Default Credentials, if you are using the ADC flow from the repo README - -## Step 1: Install Python 3.11 - -Python 3.11 was installed with Homebrew: - -```bash -brew install python@3.11 -``` - -The installed interpreter path is: +- macOS with Homebrew +- Python 3.11 (`brew install python@3.11`) +- Google Cloud CLI (`brew install --cask google-cloud-sdk`) +- A Google Ads developer token +- A Google Cloud project with the Google Ads API enabled +- OAuth client credentials JSON (for ADC login) -```bash -/opt/homebrew/bin/python3.11 -``` +--- -## Step 2: Create a Local Virtual Environment +## Step 1 — Create the Virtual Environment From the repo root: @@ -63,212 +44,195 @@ Then install the project into that environment: This installs the local entrypoint used by the repo: ```bash -./.venv/bin/google-ads-mcp +./.venv/bin/python -c "import ads_mcp.server; print('ok')" ``` -## Step 3: Verify the Local Install - -Two lightweight checks were used: - -```bash -./.venv/bin/python -c "import ads_mcp.server; print('server import ok')" -./.venv/bin/python -m unittest tests.server_test -``` +--- -These confirm the package imports correctly and the basic server initialization test passes. +## Step 2 — Authenticate with Application Default Credentials -## Step 4: Install Google Cloud CLI - -The Google Cloud CLI was installed with Homebrew: +Use the local `gcloud` wrapper so credentials stay inside `.gcloud/` instead of your global `~/.config/gcloud`: ```bash -brew install --cask google-cloud-sdk +./gcloud-local.sh auth application-default login \ + --scopes https://www.googleapis.com/auth/adwords,https://www.googleapis.com/auth/cloud-platform \ + --client-id-file /absolute/path/to/your-oauth-client.json ``` -The main executable path is: +When complete, the credentials file is at: -```bash -/opt/homebrew/bin/gcloud ``` - -## Step 5: Use a Repo-Local `gcloud` Config - -Instead of writing Google auth state to the global config directory, this setup uses `gcloud-local.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -export CLOUDSDK_PYTHON="/opt/homebrew/bin/python3.11" -export CLOUDSDK_CONFIG="${ROOT_DIR}/.gcloud" - -exec /opt/homebrew/bin/gcloud "$@" +.gcloud/application_default_credentials.json ``` -This does two things: +--- -- forces `gcloud` to use Python 3.11 -- stores credentials and config in `./.gcloud/` +## Step 3 — Configure `.env.local` -Verify it works: +Copy the template: ```bash -./gcloud-local.sh --version +cp .env.local.example .env.local ``` -## Step 6: Authenticate with Application Default Credentials +Fill in `.env.local`: -The repo README supports ADC. Use the local wrapper so credentials stay inside `.gcloud/`: +```env +GOOGLE_PROJECT_ID=your-gcp-project-id +GOOGLE_ADS_DEVELOPER_TOKEN=your-developer-token +GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/repo/.gcloud/application_default_credentials.json -```bash -./gcloud-local.sh auth application-default login \ - --scopes https://www.googleapis.com/auth/adwords,https://www.googleapis.com/auth/cloud-platform \ - --client-id-file /absolute/path/to/your-oauth-client.json +# Required if your accounts are under an MCC (manager account). +# Set this to the top-level MCC ID, digits only, no hyphens. +# All data queries must still target a leaf client account ID — see Account Structure below. +GOOGLE_ADS_LOGIN_CUSTOMER_ID=5061122756 ``` -When this succeeds, the credentials file is typically created in the repo-local config directory. In this setup, the intended path is: +--- + +## Step 4 — Start the Server ```bash -/absolute/path/to/repo/.gcloud/application_default_credentials.json +./run-local.sh ``` -## Step 7: Configure Environment Variables +This loads `.env.local` and starts `.venv/bin/google-ads-mcp`. The process listens for MCP messages over stdio. It will appear to hang — that is normal. The server is waiting for a client to connect. -The repo now includes `.env.local.example`: +--- -```env -GOOGLE_PROJECT_ID=your-gcp-project-id -GOOGLE_ADS_DEVELOPER_TOKEN=your-google-ads-developer-token -GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/application_default_credentials.json -# Optional: if you access the target account through a manager account -# GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 -``` +## Step 5 — Configure Claude Code (`.mcp.json`) -Copy it to `.env.local`: +Create `.mcp.json` at the repo root. This file is gitignored because it contains absolute local paths. -```bash -cp .env.local.example .env.local +```json +{ + "mcpServers": { + "google-ads-mcp": { + "command": "/absolute/path/to/repo/.venv/bin/google-ads-mcp", + "env": { + "GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/repo/.gcloud/application_default_credentials.json", + "GOOGLE_ADS_DEVELOPER_TOKEN": "your-developer-token", + "GOOGLE_ADS_LOGIN_CUSTOMER_ID": "5061122756" + } + } + } +} ``` -Then update `.env.local` with real values. In this workspace, `.env.local` was prefilled to expect the repo-local ADC file: +Replace `/absolute/path/to/repo` with the actual path on your machine. -```env -GOOGLE_PROJECT_ID=your-gcp-project-id -GOOGLE_ADS_DEVELOPER_TOKEN=your-google-ads-developer-token -GOOGLE_APPLICATION_CREDENTIALS=/Users/varunbhayana/Desktop/projects/google-ads-mcp/.gcloud/application_default_credentials.json -# GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 -``` +After creating this file, restart Claude Code. The MCP server starts automatically when Claude Code loads the project. -## Step 8: Start the Server +--- -Use the helper script: +## Account Structure -```bash -./run-local.sh -``` +The credential authenticates as a top-level MCC. Queries must target a **leaf client account** (a non-manager account inside the MCC): -`run-local.sh` does the following: +- `GOOGLE_ADS_LOGIN_CUSTOMER_ID` = the top-level MCC ID (e.g. `5061122756`) +- `customer_id` passed to each tool = a leaf account ID (e.g. `1673268103`) +- IDs must be digits only — remove any hyphens -- checks that `.env.local` exists -- loads env vars from `.env.local` -- starts `./.venv/bin/google-ads-mcp` +To list all leaf accounts under the MCC, use the `search` tool with: -The script contents are: +``` +resource: customer_client +fields: [ + "customer_client.client_customer", + "customer_client.descriptive_name", + "customer_client.level", + "customer_client.manager", + "customer_client.status" +] +customer_id: 5061122756 +conditions: ["customer_client.level <= 2"] +``` -```bash -#!/usr/bin/env bash -set -euo pipefail +Filter for rows where `customer_client.manager = false` — those are the queryable leaf accounts. -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${ROOT_DIR}/.env.local" +--- -if [[ ! -f "${ENV_FILE}" ]]; then - echo "Missing ${ENV_FILE}" - echo "Copy .env.local.example to .env.local and fill in your values." - exit 1 -fi +## Available MCP Tools -set -a -source "${ENV_FILE}" -set +a +### Built-in (generic) -exec "${ROOT_DIR}/.venv/bin/google-ads-mcp" -``` +| Tool | Description | +|---|---| +| `list_accessible_customers` | Returns top-level MCC IDs accessible by the credential | +| `get_resource_metadata` | Returns selectable/filterable/sortable fields for any GAQL resource | +| `search` | Runs any GAQL query against a valid resource | -## Optional: MCP Client Configuration +### Custom (curated) -If your MCP client can launch a local command directly, point it at the local binary and pass the same env vars. +| Tool | Description | +|---|---| +| `get_search_term_report` | Search terms that triggered ads — for negative keyword discovery. Inputs: `customer_id`, `start_date`, `end_date`, optional `campaign_id`, `ad_group_id`, `min_impressions`, `limit`. | -Example: +--- -```json -{ - "mcpServers": { - "google-ads-mcp": { - "command": "/absolute/path/to/google-ads-mcp/.venv/bin/google-ads-mcp", - "env": { - "GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/application_default_credentials.json", - "GOOGLE_PROJECT_ID": "your-gcp-project-id", - "GOOGLE_ADS_DEVELOPER_TOKEN": "your-developer-token" - } - } - } -} -``` +## Example: Run `get_search_term_report` -## Security Notes +```python +get_search_term_report( + customer_id="1635583349", # Stage_Haryanavi_2025 — digits only, no hyphens + start_date="2026-01-01", + end_date="2026-05-06", + min_impressions=10, + limit=200, +) +``` -- Do not commit `.env.local`. -- Do not commit `.gcloud/`. -- Do not paste service account keys or OAuth credentials into tracked files. -- If a private key or service account JSON was exposed in chat, logs, or a file, rotate it immediately in Google Cloud IAM. +Returns rows with: `search_term`, `status` (ADDED / EXCLUDED / NONE), `triggering_keyword`, `match_type`, `impressions`, `clicks`, `ctr`, `cost_micros`, `conversions`, `top_impression_pct`, `abs_top_impression_pct`. -## File Reference +`status = NONE` means the term is not yet added as a keyword or negative — these are the candidates to review for negative keyword additions. -| File | Purpose | -|---|---| -| `LOCAL_DEVELOPMENT.md` | This local runbook | -| `run-local.sh` | Loads `.env.local` and starts the server | -| `gcloud-local.sh` | Runs `gcloud` with repo-local config and Python 3.11 | -| `.env.local.example` | Template for required env vars | -| `.env.local` | Local credentials and config values | -| `.gcloud/` | Repo-local Google Cloud CLI config and ADC state | -| `.venv/` | Local Python virtual environment | +--- ## Troubleshooting **`Missing .env.local`** -Create it from the template: - ```bash cp .env.local.example .env.local ``` **`GOOGLE_ADS_DEVELOPER_TOKEN environment variable not set`** -Fill in `GOOGLE_ADS_DEVELOPER_TOKEN` inside `.env.local`. +Fill in `GOOGLE_ADS_DEVELOPER_TOKEN` in `.env.local`. **`google-ads-mcp: command not found`** -Reinstall the editable package: - ```bash ./.venv/bin/pip install -e . ``` +**`Metrics cannot be requested for a manager account`** + +You passed an MCC ID as `customer_id`. Use a leaf (non-manager) client account ID instead. See Account Structure above. + +**`User doesn't have permission to access customer`** + +The `GOOGLE_ADS_LOGIN_CUSTOMER_ID` is set but the account you are querying is not under that MCC, or the MCC header is missing. Verify the hierarchy using `customer_client`. + **`The developer token is only approved for use with test accounts`** -Your token does not yet have the required production access level. See the [Google Ads access levels documentation](https://developers.google.com/google-ads/api/docs/access-levels). +Your token does not have production access. See the Google Ads access levels documentation. **ADC or auth errors** -Repeat the login flow with the local wrapper: +Re-run the login flow: ```bash ./gcloud-local.sh auth application-default login \ --scopes https://www.googleapis.com/auth/adwords,https://www.googleapis.com/auth/cloud-platform \ --client-id-file /absolute/path/to/your-oauth-client.json ``` + +--- + +## Security Notes + +- Do not commit `.env.local` — it contains real credentials +- Do not commit `.gcloud/` — it contains ADC tokens +- Do not commit `.mcp.json` — it contains absolute local paths and tokens +- If credentials were accidentally exposed, rotate them immediately in Google Cloud IAM diff --git a/MCP_CHANGE_PLAN.md b/MCP_CHANGE_PLAN.md index 73c9b34..d545f3e 100644 --- a/MCP_CHANGE_PLAN.md +++ b/MCP_CHANGE_PLAN.md @@ -1,846 +1,142 @@ # MCP Change Plan for Required Google Ads Reporting -This document explains, step by step, what changes would be needed if you want this MCP server to support only the Google Ads reporting use cases you listed while limiting generic campaign and ad-group reporting that you already have elsewhere. - -No code changes are made by this document. This is a planning and implementation guide only. - ## Goal -You want the MCP server to support these reporting categories: - -- Impression Share -- Lost IS (Budget) -- Lost IS (Rank) -- Quality Score -- Ad Rank -- Keyword-level efficiency -- Search Term Report for negative keyword discovery -- Top-of-page rate -- Absolute top-of-page rate +Support curated Google Ads reporting for these use cases while blocking generic daily/hourly campaign and ad-group reporting that already exists in the existing analytics stack: + +- Impression Share, Lost IS (Budget), Lost IS (Rank) +- Quality Score, Ad Rank, keyword-level efficiency +- Search Term Report — negative keyword discovery +- Top-of-page rate / Absolute top-of-page rate - Audience analysis - Change history - Reach & Frequency -- Reach / frequency union and overlap math +- Reach / frequency union & overlap math - Auction Insights - Keywords - Product catalog - TAM / audience size -You also want to avoid exposing broad generic reporting such as campaign or ad-group daily/hourly insights because that already exists in your current system. +--- + +## Validated Capability Matrix (2026-05-06) -## Current State of This MCP Server +All items below were validated against the live Google Ads API using `get_resource_metadata` and real account data. -The current MCP server is generic and thin. It mainly exposes: +| # | Requirement | Status | Resource(s) | Key Fields | +|---|---|---|---|---| +| 1 | Impression Share | Available now | `keyword_view`, `campaign`, `ad_group` | `metrics.search_impression_share`, `metrics.search_exact_match_impression_share`, `metrics.search_absolute_top_impression_share` | +| 2 | Lost IS (Budget) | Available now | `keyword_view`, `campaign`, `ad_group` | `metrics.search_budget_lost_impression_share`, `metrics.search_budget_lost_top_impression_share`, `metrics.search_budget_lost_absolute_top_impression_share` | +| 3 | Lost IS (Rank) | Available now | `keyword_view`, `campaign`, `ad_group` | `metrics.search_rank_lost_impression_share`, `metrics.search_rank_lost_top_impression_share`, `metrics.search_rank_lost_absolute_top_impression_share` | +| 4 | Quality Score | Available now | `ad_group_criterion` | `ad_group_criterion.quality_info.quality_score`, `.creative_quality_score`, `.post_click_quality_score`, `.search_predicted_ctr`; also `metrics.historical_quality_score` on `keyword_view` | +| 5 | Ad Rank | Not directly available | — | Not a GAQL field. Use Quality Score + IS (rank lost) as a composite proxy. | +| 6 | Keyword-level efficiency | Available now | `keyword_view`, `ad_group_criterion` | `metrics.impressions`, `metrics.clicks`, `metrics.ctr`, `metrics.cost_micros`, `metrics.conversions`, `metrics.cost_per_conversion` | +| 7 | Search Term Report (negative kw discovery) | **Done — custom tool built** | `search_term_view` | See `get_search_term_report` below | +| 8 | Top-of-page rate | Available now | `keyword_view`, `campaign`, `ad_group`, `search_term_view` | `metrics.top_impression_percentage`, `metrics.search_top_impression_share` | +| 9 | Absolute top-of-page rate | Available now | same as above | `metrics.absolute_top_impression_percentage`, `metrics.search_absolute_top_impression_share` | +| 10 | Audience analysis | Available now | `campaign_audience_view`, `ad_group_audience_view`, `user_list` | Standard performance metrics on audience views; `user_list.description`, `audience.name` | +| 11 | Change history | Available now | `change_event` | `change_event.change_date_time`, `.change_resource_type`, `.resource_change_operation`, `.changed_fields`, `.new_resource`, `.old_resource`, `.user_email` — note: LIMIT ≤ 10000 required | +| 12 | Reach & Frequency | Partial | `campaign`, `ad_group` | Standard impression metrics available everywhere. True `metrics.reach` exists only for Video/Display campaign types, not Search. | +| 13 | Reach/frequency union & overlap math | Not available as raw output | — | No GAQL resource exposes overlap math. Requires a custom derived tool that queries multiple audience sizes and computes intersection/union in Python. | +| 14 | Auction Insights | Available now | `keyword_view`, `campaign`, `ad_group` | `metrics.auction_insight_search_impression_share`, `metrics.auction_insight_search_overlap_rate`, `metrics.auction_insight_search_outranking_share`, `metrics.auction_insight_search_position_above_rate`, `metrics.auction_insight_search_top_impression_percentage`, `metrics.auction_insight_search_absolute_top_impression_percentage` | +| 15 | Keywords | Available now | `keyword_view`, `ad_group_criterion` | Full keyword attributes + all efficiency and IS metrics | +| 16 | Product catalog | Available now | `shopping_performance_view`, `shopping_product`, `product_group_view` | IS metrics confirmed on `shopping_performance_view`; product attributes on `shopping_product` | +| 17 | TAM / audience size | Partial | `user_list` | `user_list.size_for_search`, `user_list.size_range_for_search`, `user_list.size_for_display`, `user_list.size_range_for_display` — confirmed available. TAM as a planning/forecasting concept is not in GAQL. | -- `search` -- `get_resource_metadata` -- `list_accessible_customers` +--- -Relevant files: +## Generic Reporting to Block -- `ads_mcp/tools/search.py` -- `ads_mcp/tools/get_resource_metadata.py` -- `ads_mcp/tools/core.py` -- `ads_mcp/server.py` -- `ads_mcp/gaql_resources.txt` +These patterns are available in the current `search` tool but overlap with the existing analytics stack. A policy layer in `search.py` should block them: -This means the server currently behaves as a general Google Ads GAQL query layer, not a curated business-report API. +| Block pattern | Reason | +|---|---| +| `campaign` + `segments.date` | Daily campaign performance — already covered | +| `campaign` + `segments.hour` | Hourly campaign performance — already covered | +| `ad_group` + `segments.date` | Daily ad group performance — already covered | +| `ad_group` + `segments.hour` | Hourly ad group performance — already covered | +| `ad_group` + `segments.day_of_week` | Day-of-week ad group breakdown — already covered | -## How Your Repo Will Be Configured with MCP +**Exception:** `campaign` and `ad_group` queries that select IS, Quality Score, or Auction Insight metrics must remain allowed even if they include `segments.date`. These are additive, not overlapping. -This is an important distinction: your repo does not become "MCP-enabled" only by editing application code. The MCP integration has two layers: +--- -- the MCP server setup -- the MCP client configuration +## Account Structure -### Layer 1: MCP server setup inside or alongside the repo +The credential authenticates as top-level MCC `5061122756`. All data queries must target a **leaf client account** (a non-manager account under the MCC hierarchy). -This repository already acts as the MCP server codebase. +- `GOOGLE_ADS_LOGIN_CUSTOMER_ID` must be set to the MCC ID without hyphens: `5061122756` +- `customer_id` passed to each tool must be a leaf account ID (e.g. `1673268103`, `1635583349`) +- `list_accessible_customers` returns top-level MCC IDs only, not the leaf accounts +- Use the `customer_client` resource on the MCC to enumerate leaf accounts: -For local usage, the server is run from this repo using: +``` +SELECT customer_client.client_customer, customer_client.level, + customer_client.manager, customer_client.descriptive_name +FROM customer_client +WHERE customer_client.level <= 2 +``` -- `.venv` for Python dependencies -- `.env.local` for credentials and runtime variables -- `run-local.sh` to start the server -- `gcloud-local.sh` to manage repo-local Google auth state +--- -In practical terms, this means your repo is configured to host the MCP server process itself. +## Code Changes -### Layer 2: MCP client configuration outside the repo +### Done -Your MCP client is what actually connects to the server. This is usually configured outside the repo in a client settings file such as: +**`ads_mcp/tools/search_term_report.py`** — new file -- `~/.claude/settings.json` -- `~/.gemini/settings.json` -- `.cursor/mcp.json` -- `.vscode/mcp.json` +Custom MCP tool `get_search_term_report`. Fixed to the `search_term_view` resource. Inputs: `customer_id`, `start_date`, `end_date`, optional `campaign_id`, `ad_group_id`, `min_impressions`, `limit`. Returns clean named fields per row: `search_term`, `status`, `triggering_keyword`, `match_type`, `impressions`, `clicks`, `ctr`, `cost_micros`, `conversions`, `top_impression_pct`, `abs_top_impression_pct`. Live-tested against real accounts. -That config points the client to this repo's local server command or HTTP endpoint. +**`ads_mcp/server.py`** — one line added + +```python +from ads_mcp.tools import search, core, get_resource_metadata, search_term_report # noqa: F401 +``` -Example local command-based configuration: +**`.mcp.json`** — new file at repo root + +Registers the MCP server with Claude Code so the tool is callable from the IDE: ```json { "mcpServers": { "google-ads-mcp": { - "command": "/absolute/path/to/google-ads-mcp/.venv/bin/google-ads-mcp", + "command": "/absolute/path/to/.venv/bin/google-ads-mcp", "env": { - "GOOGLE_APPLICATION_CREDENTIALS": "/absolute/path/to/google-ads-mcp/.gcloud/application_default_credentials.json", - "GOOGLE_PROJECT_ID": "your-gcp-project-id", - "GOOGLE_ADS_DEVELOPER_TOKEN": "your-developer-token" + "GOOGLE_APPLICATION_CREDENTIALS": "...", + "GOOGLE_ADS_DEVELOPER_TOKEN": "...", + "GOOGLE_ADS_LOGIN_CUSTOMER_ID": "5061122756" } } } } ``` -### What this means for your main application repo - -If your separate application repo needs to "use MCP," the usual flow is: - -1. keep Google Ads access logic inside this MCP server -2. configure the MCP client used by developers or agents -3. let your application workflows call approved MCP tools instead of directly querying Google Ads everywhere - -This can be done in two ways: - -### Option A: MCP is used only by developers or AI agents - -In this model: - -- your application code does not change much at first -- your MCP-enabled tooling connects to this Google Ads MCP server -- developers or agents use the server for reporting and analysis tasks - -This is the fastest and lowest-risk rollout. - -### Option B: Your application stack uses MCP as an internal service layer - -In this model: - -- your app or orchestration layer calls MCP tools as part of product workflows -- the MCP server becomes a controlled Google Ads data access layer -- custom tools can be added for approved business use cases - -This is the better long-term architecture if you want controlled, reusable reporting access. - -### Recommended configuration flow - -If you move ahead, the repo-level configuration flow should be: - -1. keep the MCP server code and local setup in this repo -2. keep secrets local and out of git -3. add MCP client config in the consuming environment, not in committed secret-bearing files -4. document which tools and report categories are allowed -5. only then decide whether your main application repo should call generic search or custom business-specific MCP tools - -### What should be committed vs local-only - -Safe to commit: - -- documentation -- helper scripts -- example env templates -- ignore rules -- future custom tool code - -Do not commit: - -- `.env.local` -- `.gcloud/` -- `.venv/` -- real credentials or tokens - -### Bottom line - -Your repo will be configured with MCP through: - -- local server setup in this repo -- external MCP client configuration that points to this repo's server -- optional future integration from your main application workflows into approved MCP tools - -So the first configuration step is operational, not business-logic code changes. - -## Chosen Access Model - -The intended design model for this MCP server is: - -- one credential -- multiple clients' data - -More precisely: - -- one shared Google credential authenticates the MCP server -- the MCP client passes `customer_id` dynamically per request -- the server returns data for whichever Google Ads client account that credential is authorized to access -- if access is through a manager account, `login_customer_id` provides the manager context - -This means the access pattern is: - -- shared authentication identity -- dynamic target account selection -- multiple client accounts behind one server - -### Practical interpretation - -In this model: - -- `GOOGLE_APPLICATION_CREDENTIALS` identifies who is calling Google -- `GOOGLE_ADS_LOGIN_CUSTOMER_ID` identifies the manager account context, when needed -- `customer_id` identifies the client account whose data is being queried - -### Important implication - -Because one credential can potentially access multiple client accounts, this model should eventually include MCP-side restrictions such as: - -- an allowlist of approved `customer_id`s -- optional filtering of `list_accessible_customers` -- query-level policy checks on allowed report categories - -Without those restrictions, the MCP server may expose any account reachable by the shared credential. - -## Detailed Flow Diagram - -The following diagrams show how a request moves from an MCP client to Google Ads, where `customer_id` and `login_customer_id` are used, and where future restriction logic would sit. - -### 1. High-Level MCP Flow - -```text -+-------------------+ -| MCP Client | -| Claude/Cursor/etc | -+---------+---------+ - | - | tool call - | search(customer_id, resource, fields, ...) - v -+---------+---------+ -| MCP Server | -| google-ads-mcp | -+---------+---------+ - | - | validates request - | applies future policy rules - v -+---------+---------+ -| Google Ads Client | -| built from ADC | -+---------+---------+ - | - | Google Ads API request - v -+---------+---------+ -| Google Ads API | -+---------+---------+ - | - | GAQL results - v -+---------+---------+ -| MCP Server | -| formats response | -+---------+---------+ - | - | MCP tool response - v -+-------------------+ -| MCP Client | -+-------------------+ -``` - -### 2. Account and Credential Flow - -```text -+--------------------------------------------------------------+ -| Local runtime config | -| .env.local | -| - GOOGLE_APPLICATION_CREDENTIALS | -| - GOOGLE_ADS_LOGIN_CUSTOMER_ID (optional, usually MCC) | -+------------------------------+-------------------------------+ - | - v -+--------------------------------------------------------------+ -| ADC credentials | -| application_default_credentials.json | -| "Who is calling Google?" | -+------------------------------+-------------------------------+ - | - v -+--------------------------------------------------------------+ -| MCP server request context | -| - login_customer_id from env | -| - customer_id passed dynamically by MCP client | -+------------------------------+-------------------------------+ - | - v -+--------------------------------------------------------------+ -| Google Ads request meaning | -| login_customer_id = manager / access context | -| customer_id = target ads account whose data is queried | -+--------------------------------------------------------------+ -``` - -### 3. Recommended Restriction Flow - -```text -+--------------------+ -| MCP Client | -| passes customer_id | -+---------+----------+ - | - v -+---------+----------+ -| search tool | -| ads_mcp/tools/ | -| search.py | -+---------+----------+ - | - | Step A: validate customer_id - | - allowed account? - | - approved child under MCC? - | - | Step B: validate query shape - | - blocked resource? - | - blocked segment? - | - blocked combination? - v -+---------+----------+ -| allowed? | -+----+-----------+---+ - | | - no| |yes - | | - v v -+----+----+ +--+------------------+ -| return | | execute Google Ads | -| MCP | | API request | -| error | +--+------------------+ -+---------+ | - v - +-----+------------------+ - | format and return data | - +------------------------+ -``` - -### 4. Future Curated Tool Flow - -If you later add custom business-specific tools, the flow becomes more controlled: - -```text -+----------------------+ -| MCP Client | -| calls approved tool | -| e.g. search term | -+----------+-----------+ - | - v -+----------+-----------+ -| Custom MCP Tool | -| ads_mcp/tools/... | -+----------+-----------+ - | - | fixed resource mapping - | fixed allowed metrics - | fixed allowed segments - v -+----------+-----------+ -| internal GAQL query | -+----------+-----------+ - | - v -+----------+-----------+ -| Google Ads API | -+----------+-----------+ - | - v -+----------------------+ -| standard response | -+----------------------+ -``` - -### 5. End-to-End Decision Flow - -This is the most practical way to think about the final design: - -```text -1. Client authenticates to MCP environment -2. Client calls MCP tool -3. MCP server receives dynamic customer_id -4. MCP server applies account-level restrictions -5. MCP server applies report/query-level restrictions -6. If blocked, return MCP error -7. If allowed, build Google Ads request -8. Use ADC credentials for authentication -9. Use optional login_customer_id as manager context -10. Query target customer_id -11. Format results -12. Return approved data to client -``` - -### Where Each Concern Lives - -```text -Authentication identity - -> GOOGLE_APPLICATION_CREDENTIALS - -Manager access context - -> GOOGLE_ADS_LOGIN_CUSTOMER_ID - -Dynamic target account - -> customer_id passed to search/custom tool - -Account allowlisting - -> future validation layer in search.py / tool wrappers - -Report-type restrictions - -> future validation layer in search.py / tool wrappers - -Approved business outputs - -> future custom tools in ads_mcp/tools/ -``` - -## What Is Already Available Today - -These items are likely already available through the existing `search` tool, assuming the required Google Ads fields and metrics are supported by GAQL for the chosen resource: - -- Impression Share -- Lost IS (Budget) -- Lost IS (Rank) -- Quality Score -- Keyword-level efficiency -- Search Term Report for negative keyword discovery -- Top-of-page rate -- Absolute top-of-page rate -- Audience analysis -- Change history -- Keywords -- Product catalog / shopping-style product reporting - -These are likely partial or need validation: - -- Reach & Frequency -- TAM / audience size - -These are not clean first-class capabilities in the current MCP server and would likely need custom handling or may not be available directly: - -- Ad Rank -- Reach / frequency union and overlap math -- Auction Insights - -## Likely Resource Mapping - -These are the main Google Ads resource families you would likely use for each need. - -### Impression Share, Lost IS (Budget), Lost IS (Rank) - -Potential resources: - -- `campaign` -- `ad_group` -- `keyword_view` -- `ad_group_criterion` - -### Quality Score and keyword-level efficiency - -Potential resources: - -- `keyword_view` -- `ad_group_criterion` - -### Search Term Report - -Potential resources: - -- `search_term_view` -- `campaign_search_term_view` -- `customer_search_term_insight` -- `dynamic_search_ads_search_term_view` -- `smart_campaign_search_term_view` - -### Top-of-page and absolute top-of-page rate - -Potential resources: - -- `keyword_view` -- `ad_group_criterion` -- `campaign` -- `ad_group` - -### Audience analysis - -Potential resources: - -- `campaign_audience_view` -- `ad_group_audience_view` -- `audience` -- `combined_audience` -- `custom_audience` -- `user_list` - -### Change history - -Potential resource: - -- `change_event` - -### Keywords - -Potential resources: - -- `keyword_view` -- `ad_group_criterion` -- `display_keyword_view` - -### Product catalog - -Potential resources: - -- `shopping_product` -- `shopping_performance_view` -- `product_group_view` -- `asset_group_product_group_view` - -## What Should Be Limited - -Since you already have campaign and ad-group daily/hourly reporting elsewhere, the MCP server should eventually avoid exposing those generic analytics patterns. - -The main things to limit later are: - -- generic `campaign` reporting -- generic `ad_group` reporting -- daily segmented reporting using `segments.date` -- hourly segmented reporting using `segments.hour` -- day-of-week breakdowns if those overlap with your current system -- broad metric exploration outside your approved use cases - -## Recommended Implementation Strategy - -The cleanest path is not to rely forever on unrestricted GAQL search. Instead, move in phases. - -## Phase 1: Capability Validation - -Before changing behavior, validate exactly which requested categories are truly available via Google Ads GAQL. - -### Step 1 - -Use `get_resource_metadata` on the most relevant resources: - -- `keyword_view` -- `ad_group_criterion` -- `search_term_view` -- `campaign_search_term_view` -- `campaign_audience_view` -- `ad_group_audience_view` -- `change_event` -- `shopping_product` -- `shopping_performance_view` -- `product_group_view` - -Purpose: - -- confirm that required metrics and segments exist -- confirm whether the fields you need are selectable and filterable - -### Step 2 - -Create a business mapping sheet with these columns: - -- requirement -- candidate resource -- candidate metrics -- candidate segments -- status -- notes - -Status should be one of: - -- available now -- available with custom wrapper -- partially available -- not available - -### Step 3 - -Explicitly validate the uncertain cases: - -- Ad Rank -- Auction Insights -- Reach & Frequency -- TAM / audience size -- Reach/frequency overlap math - -These should not be assumed to exist until validated. - -## Phase 2: Define the Allowed Scope - -Once capability validation is done, define what the MCP server is allowed to expose. - -### Step 4 - -Create an allowlist of approved reporting categories: - -- keyword insights -- search term analysis -- audience analysis -- change history -- product and shopping reporting -- impression-share-style metrics - -### Step 5 - -Create a denylist of broad reporting categories that should not be exposed: - -- generic campaign reporting -- generic ad-group reporting -- daily campaign insights -- hourly campaign insights -- daily ad-group insights -- hourly ad-group insights - -### Step 6 - -Define whether the denylist should block: - -- resources -- segments -- metrics -- or combinations of all three - -In practice, combination-based blocking is usually best. For example: - -- allow `campaign` only for impression-share-related queries -- block `campaign` when combined with `segments.date` -- block `ad_group` when combined with `segments.hour` - -## Phase 3: Decide the Product Shape - -You have two design options. - -### Option A: Restrict the generic `search` tool - -This keeps the current architecture but adds safety rules. - -What would change: - -- parse inputs to the `search` tool -- reject disallowed resources -- reject disallowed segments -- reject disallowed combinations -- return a clear MCP error message when a blocked query is attempted - -Best file for this: - -- `ads_mcp/tools/search.py` - -### Option B: Add custom purpose-built tools - -This is the cleaner long-term design if you want predictable outputs for downstream agents or application workflows. - -Examples of future tools: - -- `get_impression_share_report` -- `get_keyword_efficiency_report` -- `get_search_term_discovery_report` -- `get_audience_analysis_report` -- `get_change_history_report` -- `get_product_catalog_report` - -What would change: - -- add new files under `ads_mcp/tools/` -- register them via imports in `ads_mcp/server.py` -- optionally reduce or hide generic search from some environments - -Recommended approach: - -- use both -- keep `search` but restrict it -- add custom tools for the highest-value business reports - -## Phase 4: Implement Restrictions - -If you choose to limit generic access, these are the step-by-step code changes. - -### Step 7 - -Add a policy layer in `ads_mcp/tools/search.py`. - -The policy should inspect: - -- `resource` -- `fields` -- `conditions` -- `orderings` - -It should decide whether the query is: - -- allowed -- blocked -- allowed only for specific metric patterns - -### Step 8 - -Create explicit validation rules such as: - -- block `campaign` with `segments.date` -- block `campaign` with `segments.hour` -- block `ad_group` with `segments.date` -- block `ad_group` with `segments.hour` -- allow `keyword_view` for quality score and efficiency metrics -- allow `search_term_view` for discovery use cases -- allow `change_event` -- allow audience view resources -- allow shopping/product resources - -### Step 9 - -Add human-readable error messages when a query is blocked. - -Examples: - -- "Generic campaign daily insights are not available through this MCP server." -- "Use the keyword or search term reporting tools for this use case." - -### Step 10 - -Optionally restrict metadata discovery in `ads_mcp/tools/get_resource_metadata.py` so agents cannot freely discover resources you intend to block. - -Without this step, an agent may still see blocked resources even if it cannot query them. - -## Phase 5: Add Custom Reporting Tools - -If you decide to create a more curated interface, use these steps. - -### Step 11 - -Create one tool per business capability group instead of one tool per metric. - -Suggested grouping: - -- keyword and competitiveness insights -- search term discovery -- audience analysis -- change history -- product and catalog reporting - -### Step 12 - -For each tool, define: - -- required inputs -- allowed segments -- allowed filters -- default date range behavior -- output shape - -Example for search term discovery: - -- input: `customer_id`, date range, campaign filter, ad group filter -- output: search terms, impressions, clicks, cost, conversions, CTR, match context -- guardrail: no unrelated generic campaign reporting - -### Step 13 - -Register these new tools in `ads_mcp/server.py`. - -## Phase 6: Handle the Uncertain or Advanced Requests - -These need special treatment. - -### Ad Rank - -Treat this as unconfirmed until proven available from Google Ads fields you can access through GAQL. If not directly available, document it as unsupported. - -### Auction Insights - -Treat this as unconfirmed until validated. If Google Ads does not expose it in the form you need through GAQL, it should be marked unsupported in the MCP server. - -### Reach & Frequency - -Validate first. If partially available, it may need a dedicated wrapper tool that standardizes the available fields and explains limitations. - -### Reach / frequency union and overlap math - -This is likely not a raw Google Ads resource output. It probably belongs in: - -- a custom analytics layer -- or a custom MCP tool that combines multiple source queries and computes derived results - -### TAM / audience size - -This may depend on your exact meaning of TAM. If it maps to available audience-size-style data, it may be partially supported. If it requires planning or forecasting semantics, it may need a dedicated derived tool. - -## Phase 7: Testing Plan - -Once implementation starts, validate behavior in layers. - -### Step 14 - -Add unit tests for any query restriction logic. - -Likely test file location: - -- `tests/tools/` - -### Step 15 - -Add tool-level tests for each custom reporting tool that is introduced. - -### Step 16 - -Add smoke tests for: - -- allowed keyword reports -- allowed search term reports -- allowed audience reports -- blocked campaign daily reports -- blocked ad-group hourly reports - -## Suggested Deliverables - -If you move ahead, the implementation should probably produce: - -### Deliverable 1 - -A capability matrix that says: - -- requirement -- supported now -- supported after wrapper -- unsupported - -### Deliverable 2 - -A restriction policy for generic search. - -### Deliverable 3 - -Custom MCP tools for the highest-priority approved use cases. - -### Deliverable 4 - -Tests covering both allowed and blocked scenarios. - -## Recommended Order of Work - -If you want the safest rollout, do the work in this order: +### Remaining — Custom Tools -1. Validate exact field support using `get_resource_metadata` -2. Create the capability matrix -3. Finalize the allowlist and denylist -4. Restrict generic `search` -5. Add custom tools for approved report families -6. Add tests -7. Update docs for client usage +| Tool | File | Resource | Status | +|---|---|---|---| +| `get_keyword_quality_report` | `ads_mcp/tools/keyword_quality.py` | `ad_group_criterion` + `keyword_view` | Not started | +| `get_auction_insights_report` | `ads_mcp/tools/auction_insights.py` | `keyword_view` / `campaign` | Not started | +| `get_audience_analysis_report` | `ads_mcp/tools/audience_analysis.py` | `campaign_audience_view`, `user_list` | Not started | +| `get_change_history_report` | `ads_mcp/tools/change_history.py` | `change_event` | Not started | +| `get_impression_share_report` | `ads_mcp/tools/impression_share.py` | `keyword_view`, `campaign` | Not started | -## Final Recommendation +### Remaining — Policy Layer -Do not start by building everything as one open-ended search surface. +Add a validation function at the top of `ads_mcp/tools/search.py` that inspects `resource`, `fields`, and `conditions` before executing the query. Block combinations listed in the table above. Return a clear error message naming what is blocked and why. -A better approach is: +--- -- validate what Google Ads truly supports -- restrict generic campaign/ad-group daily/hourly exploration -- keep keyword, search term, audience, change history, and product reporting -- add custom tools for the business flows you care about most +## Relevant Files -That gives you a more controlled MCP server and avoids overlapping with analytics you already have. +| File | Purpose | +|---|---| +| `ads_mcp/server.py` | Entry point — import new tool modules here to register them | +| `ads_mcp/tools/search.py` | Generic GAQL tool — policy layer goes here | +| `ads_mcp/tools/search_term_report.py` | Search term report tool — done | +| `ads_mcp/tools/core.py` | `list_accessible_customers` | +| `ads_mcp/tools/get_resource_metadata.py` | Field discovery | +| `ads_mcp/gaql_resources.txt` | Allowlist of valid GAQL resources exposed to `search` | +| `.mcp.json` | Claude Code MCP client config | +| `.env.local` | Local credentials — not committed | diff --git a/ads_mcp/server.py b/ads_mcp/server.py index 9099502..8016e7b 100644 --- a/ads_mcp/server.py +++ b/ads_mcp/server.py @@ -20,7 +20,7 @@ # object, even though they are not directly used in this file. # The `# noqa: F401` comment tells the linter to ignore the "unused import" # warning. -from ads_mcp.tools import search, core, get_resource_metadata # noqa: F401 +from ads_mcp.tools import search, core, get_resource_metadata, search_term_report # noqa: F401 from ads_mcp.resources import ( discovery, metrics, @@ -35,10 +35,14 @@ def run_server() -> None: _CLIENT_ID = os.environ.get("GOOGLE_ADS_MCP_OAUTH_CLIENT_ID") _CLIENT_SECRET = os.environ.get("GOOGLE_ADS_MCP_OAUTH_CLIENT_SECRET") + _LOCAL_HTTP = os.environ.get("MCP_LOCAL_HTTP", "").lower() == "true" port = int(os.environ.get("PORT", "8080")) if _CLIENT_ID and _CLIENT_SECRET: mcp.run(transport="streamable-http", port=port, host="0.0.0.0") + elif _LOCAL_HTTP: + # HTTP mode without OAuth — for local development and Postman testing only + mcp.run(transport="streamable-http", port=port, host="0.0.0.0") else: mcp.run() diff --git a/ads_mcp/tools/search_term_report.py b/ads_mcp/tools/search_term_report.py new file mode 100644 index 0000000..e7023ef --- /dev/null +++ b/ads_mcp/tools/search_term_report.py @@ -0,0 +1,112 @@ +"""Search Term Report tool for negative keyword discovery.""" + +from typing import Any, Dict, List, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_search_term_report( + customer_id: str, + start_date: str, + end_date: str, + campaign_id: Optional[str] = None, + ad_group_id: Optional[str] = None, + min_impressions: int = 10, + limit: int = 200, +) -> List[Dict[str, Any]]: + """Returns search terms that triggered ads, for negative keyword discovery. + + Each row includes the search term, its current status (ADDED/EXCLUDED/NONE), + the triggering keyword and match type, and key performance metrics. + Sorted by cost descending so the highest-spend wasted terms surface first. + + Args: + customer_id: Client account ID (digits only, e.g. "1635583349"). + start_date: Start of date range in YYYY-MM-DD format. + end_date: End of date range in YYYY-MM-DD format. + campaign_id: Optional campaign ID to narrow results. + ad_group_id: Optional ad group ID to narrow results. + min_impressions: Minimum impressions to filter out noise (default 10). + limit: Maximum rows to return (default 200). + + Returns: + List of rows, each containing: + - search_term: The actual query the user typed. + - status: ADDED (already a keyword), EXCLUDED (already a negative), NONE (not added yet). + - triggering_keyword: The keyword that matched this query. + - match_type: BROAD, PHRASE, or EXACT. + - impressions: Total impressions in the date range. + - clicks: Total clicks. + - ctr: Click-through rate (0.0 – 1.0). + - cost_micros: Total cost in micros (divide by 1,000,000 for currency value). + - conversions: Total conversions. + - top_impression_pct: Fraction of impressions in top position. + - abs_top_impression_pct: Fraction of impressions in absolute top position. + """ + ga_service = utils.get_googleads_service("GoogleAdsService") + + fields = [ + "search_term_view.search_term", + "search_term_view.status", + "segments.keyword.info.match_type", + "segments.keyword.info.text", + "metrics.impressions", + "metrics.clicks", + "metrics.ctr", + "metrics.cost_micros", + "metrics.conversions", + "metrics.top_impression_percentage", + "metrics.absolute_top_impression_percentage", + ] + + conditions = [ + f"segments.date BETWEEN '{start_date}' AND '{end_date}'", + f"metrics.impressions >= {min_impressions}", + ] + + if campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + + if ad_group_id: + conditions.append(f"ad_group.id = {ad_group_id}") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM search_term_view" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY metrics.cost_micros DESC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_search_term_report query: {query}") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + stv = row.search_term_view + kw = row.segments.keyword.info + m = row.metrics + rows.append({ + "search_term": stv.search_term, + "status": stv.status.name, + "triggering_keyword": kw.text, + "match_type": kw.match_type.name, + "impressions": m.impressions, + "clicks": m.clicks, + "ctr": round(m.ctr, 4), + "cost_micros": m.cost_micros, + "conversions": round(m.conversions, 2), + "top_impression_pct": round(m.top_impression_percentage, 4), + "abs_top_impression_pct": round(m.absolute_top_impression_percentage, 4), + }) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..dfce629 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,14 @@ +services: + google-ads-mcp: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + env_file: + - .env.docker + volumes: + # Mount the credentials file from outside the repo so it is never baked into the image + - /Users/varunbhayana/Desktop/projects/google-ads-mcp/.gcloud/application_default_credentials.json:/secrets/adc.json:ro + environment: + GOOGLE_APPLICATION_CREDENTIALS: /secrets/adc.json From c26d45173c2387f2ffd50f78fa75dc96409bc7e4 Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Thu, 7 May 2026 13:31:26 +0530 Subject: [PATCH 4/8] in progress --- gcloud-local.sh | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100755 gcloud-local.sh diff --git a/gcloud-local.sh b/gcloud-local.sh new file mode 100755 index 0000000..d79d7e9 --- /dev/null +++ b/gcloud-local.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +export CLOUDSDK_PYTHON="/opt/homebrew/bin/python3.11" +export CLOUDSDK_CONFIG="${ROOT_DIR}/.gcloud" + +exec /opt/homebrew/bin/gcloud "$@" From ee571390498060d310f00f9a63779ee9ef92e91f Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Thu, 7 May 2026 14:39:23 +0530 Subject: [PATCH 5/8] added mcp servers for impression and key --- ads_mcp/tools/impression_share_report.py | 183 +++++++++++++++++++++++ ads_mcp/tools/keyword_quality_report.py | 132 ++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 ads_mcp/tools/impression_share_report.py create mode 100644 ads_mcp/tools/keyword_quality_report.py diff --git a/ads_mcp/tools/impression_share_report.py b/ads_mcp/tools/impression_share_report.py new file mode 100644 index 0000000..eb76537 --- /dev/null +++ b/ads_mcp/tools/impression_share_report.py @@ -0,0 +1,183 @@ +"""Impression Share, Lost IS (Budget/Rank), Top-of-page and Abs-top-of-page report.""" + +from typing import Any, Dict, List, Literal, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_impression_share_report( + customer_id: str, + start_date: str, + end_date: str, + level: Literal["keyword", "ad_group", "campaign"] = "keyword", + campaign_id: Optional[str] = None, + ad_group_id: Optional[str] = None, + limit: int = 200, +) -> List[Dict[str, Any]]: + """Returns Impression Share, Lost IS (Budget), Lost IS (Rank), top-of-page and + absolute-top-of-page metrics. Sorted by search impression share ascending so the + weakest performers surface first. + + Note on date range: Google Ads IS metrics do NOT support segments.date at keyword + level — this is a platform limitation. At keyword level start_date/end_date are + ignored and IS reflects a recent rolling period. Date filtering works at ad_group + and campaign level. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. Used at ad_group/campaign level only. + end_date: End date in YYYY-MM-DD format. Used at ad_group/campaign level only. + level: Granularity — "keyword", "ad_group", or "campaign". Default "keyword". + campaign_id: Optional. Filter to a single campaign. + ad_group_id: Optional. Filter to a single ad group (only used at keyword/ad_group level). + limit: Maximum rows to return (default 200). + + Returns: + List of rows with: + - entity: name of the keyword / ad group / campaign + - match_type: BROAD, PHRASE, EXACT (keyword level only) + - campaign_name, ad_group_name (where applicable) + - search_impression_share: fraction of eligible impressions received (0.0–1.0) + - search_budget_lost_impression_share: IS lost to budget + - search_rank_lost_impression_share: IS lost to rank + - search_absolute_top_impression_share: IS at absolute top position + - search_budget_lost_absolute_top_impression_share + - search_rank_lost_absolute_top_impression_share + - top_impression_pct: fraction of impressions shown at top of page + - abs_top_impression_pct: fraction of impressions at absolute top + - impressions, clicks, cost_micros + """ + resource_map = { + "keyword": "keyword_view", + "ad_group": "ad_group", + "campaign": "campaign", + } + resource = resource_map[level] + + # keyword_view only supports base IS metrics — budget/rank lost variants are + # unavailable at keyword level (Google Ads API limitation). + keyword_metrics = [ + "metrics.search_impression_share", + "metrics.search_exact_match_impression_share", + "metrics.search_absolute_top_impression_share", + "metrics.search_top_impression_share", + "metrics.top_impression_percentage", + "metrics.absolute_top_impression_percentage", + "metrics.impressions", + "metrics.clicks", + "metrics.cost_micros", + ] + + # campaign and ad_group support the full IS set including budget/rank lost variants. + full_metrics = keyword_metrics + [ + "metrics.search_budget_lost_impression_share", + "metrics.search_rank_lost_impression_share", + "metrics.search_budget_lost_absolute_top_impression_share", + "metrics.search_rank_lost_absolute_top_impression_share", + "metrics.search_budget_lost_top_impression_share", + "metrics.search_rank_lost_top_impression_share", + ] + + if level == "keyword": + fields = [ + "campaign.name", + "campaign.id", + "ad_group.name", + "ad_group.id", + "ad_group_criterion.keyword.text", + "ad_group_criterion.keyword.match_type", + "ad_group_criterion.status", + ] + keyword_metrics + elif level == "ad_group": + fields = [ + "campaign.name", + "campaign.id", + "ad_group.name", + "ad_group.id", + "ad_group.status", + ] + full_metrics + else: + fields = [ + "campaign.name", + "campaign.id", + "campaign.status", + "campaign.advertising_channel_type", + ] + full_metrics + + conditions = ["metrics.impressions > 0"] + + # IS metrics do not support segments.date at keyword level (Google Ads API limitation). + # Date segmentation is only supported at ad_group and campaign level. + if level != "keyword": + conditions.insert(0, f"segments.date BETWEEN '{start_date}' AND '{end_date}'") + + if level == "keyword": + conditions.append("ad_group_criterion.type = 'KEYWORD'") + conditions.append("ad_group_criterion.status != 'REMOVED'") + if level in ("keyword", "ad_group") and campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + if level == "keyword" and ad_group_id: + conditions.append(f"ad_group.id = {ad_group_id}") + if level == "ad_group" and campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM {resource}" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY metrics.search_impression_share ASC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_impression_share_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + m = row.metrics + entry: Dict[str, Any] = { + "campaign_name": row.campaign.name, + "campaign_id": str(row.campaign.id), + "search_impression_share": round(m.search_impression_share, 4), + "search_exact_match_impression_share": round(m.search_exact_match_impression_share, 4), + "search_absolute_top_impression_share": round(m.search_absolute_top_impression_share, 4), + "search_top_impression_share": round(m.search_top_impression_share, 4), + "top_impression_pct": round(m.top_impression_percentage, 4), + "abs_top_impression_pct": round(m.absolute_top_impression_percentage, 4), + "impressions": m.impressions, + "clicks": m.clicks, + "cost_micros": m.cost_micros, + } + if level == "keyword": + entry["ad_group_name"] = row.ad_group.name + entry["keyword"] = row.ad_group_criterion.keyword.text + entry["match_type"] = row.ad_group_criterion.keyword.match_type.name + entry["status"] = row.ad_group_criterion.status.name + elif level == "ad_group": + entry["ad_group_name"] = row.ad_group.name + entry["ad_group_id"] = str(row.ad_group.id) + entry["status"] = row.ad_group.status.name + entry["search_budget_lost_impression_share"] = round(m.search_budget_lost_impression_share, 4) + entry["search_rank_lost_impression_share"] = round(m.search_rank_lost_impression_share, 4) + entry["search_budget_lost_absolute_top_impression_share"] = round(m.search_budget_lost_absolute_top_impression_share, 4) + entry["search_rank_lost_absolute_top_impression_share"] = round(m.search_rank_lost_absolute_top_impression_share, 4) + else: + entry["status"] = row.campaign.status.name + entry["channel_type"] = row.campaign.advertising_channel_type.name + entry["search_budget_lost_impression_share"] = round(m.search_budget_lost_impression_share, 4) + entry["search_rank_lost_impression_share"] = round(m.search_rank_lost_impression_share, 4) + entry["search_budget_lost_absolute_top_impression_share"] = round(m.search_budget_lost_absolute_top_impression_share, 4) + entry["search_rank_lost_absolute_top_impression_share"] = round(m.search_rank_lost_absolute_top_impression_share, 4) + rows.append(entry) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) diff --git a/ads_mcp/tools/keyword_quality_report.py b/ads_mcp/tools/keyword_quality_report.py new file mode 100644 index 0000000..a9810cc --- /dev/null +++ b/ads_mcp/tools/keyword_quality_report.py @@ -0,0 +1,132 @@ +"""Keyword Quality Score, efficiency, and keyword-level performance report.""" + +from typing import Any, Dict, List, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_keyword_quality_report( + customer_id: str, + start_date: str, + end_date: str, + campaign_id: Optional[str] = None, + ad_group_id: Optional[str] = None, + min_quality_score: Optional[int] = None, + limit: int = 500, +) -> List[Dict[str, Any]]: + """Returns keyword-level Quality Score components and efficiency metrics. + + Quality Score (1–10) is Google's rating of keyword relevance. It affects Ad Rank + and CPC. This tool surfaces the three QS components so you can identify which + dimension is dragging a keyword's score down. + + Note on Ad Rank: Google does not expose Ad Rank as a direct field. Use + quality_score combined with search_rank_lost_impression_share as a proxy — + a low QS with high rank-lost IS indicates Ad Rank is limiting performance. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + campaign_id: Optional. Filter to a single campaign. + ad_group_id: Optional. Filter to a single ad group. + min_quality_score: Optional. Only return keywords at or below this score (e.g. 5 to find weak keywords). + limit: Maximum rows to return (default 500). + + Returns: + List of rows with: + - keyword: keyword text + - match_type: BROAD, PHRASE, EXACT + - status: ENABLED, PAUSED, REMOVED + - quality_score: overall QS (1–10), null if not enough data + - creative_quality_score: ad relevance component (BELOW_AVERAGE / AVERAGE / ABOVE_AVERAGE) + - post_click_quality_score: landing page experience component + - search_predicted_ctr: expected CTR component + - impressions, clicks, ctr, cost_micros, conversions, cost_per_conversion + - search_rank_lost_impression_share: IS lost due to Ad Rank (Ad Rank proxy) + - campaign_name, ad_group_name + """ + fields = [ + "campaign.name", + "campaign.id", + "ad_group.name", + "ad_group.id", + "ad_group_criterion.keyword.text", + "ad_group_criterion.keyword.match_type", + "ad_group_criterion.status", + "ad_group_criterion.quality_info.quality_score", + "ad_group_criterion.quality_info.creative_quality_score", + "ad_group_criterion.quality_info.post_click_quality_score", + "ad_group_criterion.quality_info.search_predicted_ctr", + "metrics.impressions", + "metrics.clicks", + "metrics.ctr", + "metrics.cost_micros", + "metrics.conversions", + "metrics.cost_per_conversion", + "metrics.search_rank_lost_impression_share", + "metrics.search_impression_share", + ] + + # keyword_view supports both quality_info attributes and performance metrics + # together with segments.date. ad_group_criterion does not support metrics. + conditions = [ + f"segments.date BETWEEN '{start_date}' AND '{end_date}'", + "ad_group_criterion.type = 'KEYWORD'", + "ad_group_criterion.status != 'REMOVED'", + ] + if campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + if ad_group_id: + conditions.append(f"ad_group.id = {ad_group_id}") + if min_quality_score is not None: + conditions.append(f"ad_group_criterion.quality_info.quality_score <= {min_quality_score}") + conditions.append("ad_group_criterion.quality_info.quality_score > 0") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM keyword_view" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY ad_group_criterion.quality_info.quality_score ASC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_keyword_quality_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + crit = row.ad_group_criterion + qi = crit.quality_info + m = row.metrics + rows.append({ + "campaign_name": row.campaign.name, + "ad_group_name": row.ad_group.name, + "keyword": crit.keyword.text, + "match_type": crit.keyword.match_type.name, + "status": crit.status.name, + "quality_score": qi.quality_score if qi.quality_score > 0 else None, + "creative_quality_score": qi.creative_quality_score.name, + "post_click_quality_score": qi.post_click_quality_score.name, + "search_predicted_ctr": qi.search_predicted_ctr.name, + "impressions": m.impressions, + "clicks": m.clicks, + "ctr": round(m.ctr, 4), + "cost_micros": m.cost_micros, + "conversions": round(m.conversions, 2), + "cost_per_conversion": round(m.cost_per_conversion, 2), + "search_impression_share": round(m.search_impression_share, 4), + "search_rank_lost_impression_share": round(m.search_rank_lost_impression_share, 4), + }) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) From 9c8b619e97f1e31153ce8de7481d8509bebdd57e Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Thu, 7 May 2026 14:52:21 +0530 Subject: [PATCH 6/8] added mcp servers for impression and key --- ads_mcp/server.py | 20 ++- ads_mcp/tools/auction_insights_report.py | 133 ++++++++++++++++ ads_mcp/tools/audience_analysis_report.py | 112 +++++++++++++ ads_mcp/tools/change_history_report.py | 102 ++++++++++++ ads_mcp/tools/keyword_report.py | 113 +++++++++++++ ads_mcp/tools/product_catalog_report.py | 114 +++++++++++++ ads_mcp/tools/reach_frequency_report.py | 185 ++++++++++++++++++++++ 7 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 ads_mcp/tools/auction_insights_report.py create mode 100644 ads_mcp/tools/audience_analysis_report.py create mode 100644 ads_mcp/tools/change_history_report.py create mode 100644 ads_mcp/tools/keyword_report.py create mode 100644 ads_mcp/tools/product_catalog_report.py create mode 100644 ads_mcp/tools/reach_frequency_report.py diff --git a/ads_mcp/server.py b/ads_mcp/server.py index 8016e7b..4e2077a 100644 --- a/ads_mcp/server.py +++ b/ads_mcp/server.py @@ -20,7 +20,20 @@ # object, even though they are not directly used in this file. # The `# noqa: F401` comment tells the linter to ignore the "unused import" # warning. -from ads_mcp.tools import search, core, get_resource_metadata, search_term_report # noqa: F401 +from ads_mcp.tools import ( # noqa: F401 + search, + core, + get_resource_metadata, + search_term_report, + impression_share_report, + keyword_quality_report, + auction_insights_report, + audience_analysis_report, + change_history_report, + reach_frequency_report, + keyword_report, + product_catalog_report, +) from ads_mcp.resources import ( discovery, metrics, @@ -41,8 +54,9 @@ def run_server() -> None: if _CLIENT_ID and _CLIENT_SECRET: mcp.run(transport="streamable-http", port=port, host="0.0.0.0") elif _LOCAL_HTTP: - # HTTP mode without OAuth — for local development and Postman testing only - mcp.run(transport="streamable-http", port=port, host="0.0.0.0") + # Stateless HTTP — no session ID required, each request is independent. + # For local development and Postman testing only. Do not use in production. + mcp.run(transport="streamable-http", port=port, host="0.0.0.0", stateless_http=True) else: mcp.run() diff --git a/ads_mcp/tools/auction_insights_report.py b/ads_mcp/tools/auction_insights_report.py new file mode 100644 index 0000000..5940e4e --- /dev/null +++ b/ads_mcp/tools/auction_insights_report.py @@ -0,0 +1,133 @@ +"""Auction Insights report — competitor overlap, outranking share, and position metrics.""" + +from typing import Any, Dict, List, Literal, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_auction_insights_report( + customer_id: str, + start_date: str, + end_date: str, + level: Literal["keyword", "ad_group", "campaign"] = "campaign", + campaign_id: Optional[str] = None, + ad_group_id: Optional[str] = None, + limit: int = 200, +) -> List[Dict[str, Any]]: + """Returns Auction Insights metrics broken down by competitor domain. + + Shows how your ads compete with other advertisers in the same auctions. + Each row is one competitor domain (segments.auction_insight_domain) within + a campaign, ad group, or keyword. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + level: "keyword", "ad_group", or "campaign" (default "campaign"). + campaign_id: Optional. Filter to a single campaign. + ad_group_id: Optional. Filter to a single ad group (only at keyword/ad_group level). + limit: Maximum rows to return (default 200). + + Returns: + List of rows with: + - competitor_domain: the other advertiser's display URL domain + - campaign_name (always present) + - ad_group_name (at keyword / ad_group level) + - keyword, match_type (at keyword level) + - impression_share: competitor's IS in the same auctions + - overlap_rate: fraction of your impressions where competitor also appeared + - outranking_share: fraction of auctions where your ad ranked above theirs + - position_above_rate: fraction of auctions where competitor ranked above you + - top_impression_pct: competitor's top-of-page impression fraction + - abs_top_impression_pct: competitor's absolute-top impression fraction + """ + resource_map = { + "keyword": "keyword_view", + "ad_group": "ad_group", + "campaign": "campaign", + } + resource = resource_map[level] + + auction_metrics = [ + "metrics.auction_insight_search_impression_share", + "metrics.auction_insight_search_overlap_rate", + "metrics.auction_insight_search_outranking_share", + "metrics.auction_insight_search_position_above_rate", + "metrics.auction_insight_search_top_impression_percentage", + "metrics.auction_insight_search_absolute_top_impression_percentage", + "segments.auction_insight_domain", + ] + + if level == "keyword": + fields = [ + "campaign.name", + "ad_group.name", + "ad_group_criterion.keyword.text", + "ad_group_criterion.keyword.match_type", + ] + auction_metrics + elif level == "ad_group": + fields = ["campaign.name", "ad_group.name"] + auction_metrics + else: + fields = ["campaign.name"] + auction_metrics + + conditions = [ + f"segments.date BETWEEN '{start_date}' AND '{end_date}'", + "metrics.auction_insight_search_impression_share > 0", + ] + if level == "keyword": + conditions.append("ad_group_criterion.type = 'KEYWORD'") + if campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + if ad_group_id and level in ("keyword", "ad_group"): + conditions.append(f"ad_group.id = {ad_group_id}") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM {resource}" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY metrics.auction_insight_search_impression_share DESC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_auction_insights_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + m = row.metrics + entry: Dict[str, Any] = { + "campaign_name": row.campaign.name, + "competitor_domain": row.segments.auction_insight_domain, + "impression_share": round(m.auction_insight_search_impression_share, 4), + "overlap_rate": round(m.auction_insight_search_overlap_rate, 4), + "outranking_share": round(m.auction_insight_search_outranking_share, 4), + "position_above_rate": round(m.auction_insight_search_position_above_rate, 4), + "top_impression_pct": round(m.auction_insight_search_top_impression_percentage, 4), + "abs_top_impression_pct": round(m.auction_insight_search_absolute_top_impression_percentage, 4), + } + if level in ("keyword", "ad_group"): + entry["ad_group_name"] = row.ad_group.name + if level == "keyword": + entry["keyword"] = row.ad_group_criterion.keyword.text + entry["match_type"] = row.ad_group_criterion.keyword.match_type.name + rows.append(entry) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + combined = "\n".join(error_msgs) + if "doesn't have access to metrics" in combined or "auction_insight" in combined: + raise ToolError( + "Auction Insights metrics require Standard Access on your Google Ads " + "developer token. Apply at Google Ads → Tools → API Centre. " + f"(Request ID: {ex.request_id})" + ) + raise ToolError(f"Request ID: {ex.request_id}\n" + combined) diff --git a/ads_mcp/tools/audience_analysis_report.py b/ads_mcp/tools/audience_analysis_report.py new file mode 100644 index 0000000..251d2ae --- /dev/null +++ b/ads_mcp/tools/audience_analysis_report.py @@ -0,0 +1,112 @@ +"""Audience analysis report — performance broken down by audience segment.""" + +from typing import Any, Dict, List, Literal, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_audience_analysis_report( + customer_id: str, + start_date: str, + end_date: str, + level: Literal["campaign", "ad_group"] = "ad_group", + campaign_id: Optional[str] = None, + limit: int = 200, +) -> List[Dict[str, Any]]: + """Returns performance metrics broken down by audience segment (user lists, in-market, + affinity, etc.) at campaign or ad group level. + + Each row represents one audience criterion and its performance within that campaign + or ad group. Use this to identify which audience segments drive the most conversions + or have the best CTR. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + level: "campaign" or "ad_group" (default "ad_group"). + campaign_id: Optional. Filter to a single campaign. + limit: Maximum rows to return (default 200). + + Returns: + List of rows with: + - campaign_name, campaign_id + - ad_group_name (at ad_group level) + - audience_type: type of criterion (USER_LIST, USER_INTEREST, etc.) + - impressions, clicks, ctr, cost_micros, conversions, cost_per_conversion + - top_impression_pct, abs_top_impression_pct + """ + resource = "campaign_audience_view" if level == "campaign" else "ad_group_audience_view" + + fields = [ + "campaign.name", + "campaign.id", + "ad_group_criterion.type", + "ad_group_criterion.status", + "metrics.impressions", + "metrics.clicks", + "metrics.ctr", + "metrics.cost_micros", + "metrics.conversions", + "metrics.cost_per_conversion", + "metrics.top_impression_percentage", + "metrics.absolute_top_impression_percentage", + ] + + if level == "ad_group": + fields.insert(2, "ad_group.name") + fields.insert(3, "ad_group.id") + + conditions = [ + f"segments.date BETWEEN '{start_date}' AND '{end_date}'", + "metrics.impressions > 0", + "ad_group_criterion.status != 'REMOVED'", + ] + if campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM {resource}" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY metrics.conversions DESC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_audience_analysis_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + m = row.metrics + crit = row.ad_group_criterion + entry: Dict[str, Any] = { + "campaign_name": row.campaign.name, + "campaign_id": str(row.campaign.id), + "audience_type": crit.type_.name, + "status": crit.status.name, + "impressions": m.impressions, + "clicks": m.clicks, + "ctr": round(m.ctr, 4), + "cost_micros": m.cost_micros, + "conversions": round(m.conversions, 2), + "cost_per_conversion": round(m.cost_per_conversion, 2), + "top_impression_pct": round(m.top_impression_percentage, 4), + "abs_top_impression_pct": round(m.absolute_top_impression_percentage, 4), + } + if level == "ad_group": + entry["ad_group_name"] = row.ad_group.name + entry["ad_group_id"] = str(row.ad_group.id) + rows.append(entry) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) diff --git a/ads_mcp/tools/change_history_report.py b/ads_mcp/tools/change_history_report.py new file mode 100644 index 0000000..0ec1f5e --- /dev/null +++ b/ads_mcp/tools/change_history_report.py @@ -0,0 +1,102 @@ +"""Change history report — who changed what and when.""" + +from typing import Any, Dict, List, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_change_history_report( + customer_id: str, + start_date: str, + end_date: str, + resource_type: Optional[str] = None, + campaign_id: Optional[str] = None, + limit: int = 1000, +) -> List[Dict[str, Any]]: + """Returns a log of changes made to the account — bids, budgets, statuses, ad copy, etc. + + Each row is one change event. Useful for correlating performance shifts with + account modifications and for auditing who changed what. + + Note: Google Ads limits change_event queries to a maximum of 10000 rows and + a lookback window of 30 days. start_date must be within the last 30 days. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. Must be within the last 30 days. + end_date: End date in YYYY-MM-DD format. + resource_type: Optional. Filter by resource type. Common values: + CAMPAIGN, AD_GROUP, AD, AD_GROUP_CRITERION, CAMPAIGN_BUDGET, + CAMPAIGN_CRITERION, FEED, FEED_ITEM, BIDDING_STRATEGY. + campaign_id: Optional. Filter to changes within a single campaign. + limit: Maximum rows to return (max 10000, default 1000). + + Returns: + List of rows with: + - change_date_time: timestamp of the change (RFC 3339) + - resource_type: what type of resource was changed + - operation: ADD, UPDATE, or REMOVE + - changed_fields: dot-notation list of fields that were modified + - user_email: who made the change (empty for automated changes) + - client_type: GOOGLE_ADS_WEB_CLIENT, API, GOOGLE_ADS_AUTOMATED_RULE, etc. + - campaign_id, ad_group_id (where applicable) + """ + if limit > 10000: + limit = 10000 + + fields = [ + "change_event.change_date_time", + "change_event.change_resource_type", + "change_event.resource_change_operation", + "change_event.changed_fields", + "change_event.user_email", + "change_event.client_type", + "change_event.campaign", + "change_event.ad_group", + ] + + conditions = [ + f"change_event.change_date_time >= '{start_date} 00:00:00'", + f"change_event.change_date_time <= '{end_date} 23:59:59'", + ] + if resource_type: + conditions.append(f"change_event.change_resource_type = '{resource_type}'") + if campaign_id: + conditions.append(f"change_event.campaign = 'customers/{customer_id}/campaigns/{campaign_id}'") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM change_event" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY change_event.change_date_time DESC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_change_history_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + ev = row.change_event + rows.append({ + "change_date_time": ev.change_date_time, + "resource_type": ev.change_resource_type.name, + "operation": ev.resource_change_operation.name, + "changed_fields": list(ev.changed_fields.paths), + "user_email": ev.user_email, + "client_type": ev.client_type.name, + "campaign_resource": str(ev.campaign), + "ad_group_resource": str(ev.ad_group), + }) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) diff --git a/ads_mcp/tools/keyword_report.py b/ads_mcp/tools/keyword_report.py new file mode 100644 index 0000000..5db0349 --- /dev/null +++ b/ads_mcp/tools/keyword_report.py @@ -0,0 +1,113 @@ +"""Keywords report — full keyword list with bids, status, and efficiency metrics.""" + +from typing import Any, Dict, List, Literal, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_keyword_report( + customer_id: str, + start_date: str, + end_date: str, + campaign_id: Optional[str] = None, + ad_group_id: Optional[str] = None, + status: Literal["ENABLED", "PAUSED", "ALL"] = "ENABLED", + limit: int = 1000, +) -> List[Dict[str, Any]]: + """Returns all keywords with their bids, match types, status, and performance metrics. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + campaign_id: Optional. Filter to a single campaign. + ad_group_id: Optional. Filter to a single ad group. + status: "ENABLED", "PAUSED", or "ALL" (default "ENABLED"). + limit: Maximum rows to return (default 1000). + + Returns: + List of rows with: + - keyword: keyword text + - match_type: BROAD, PHRASE, EXACT + - status: ENABLED or PAUSED + - cpc_bid_micros: max CPC bid set on the keyword (0 if using ad group default) + - quality_score: QS (1–10) + - impressions, clicks, ctr, cost_micros, conversions, cost_per_conversion + - search_impression_share + - campaign_name, ad_group_name + """ + fields = [ + "campaign.name", + "campaign.id", + "ad_group.name", + "ad_group.id", + "ad_group_criterion.keyword.text", + "ad_group_criterion.keyword.match_type", + "ad_group_criterion.status", + "ad_group_criterion.cpc_bid_micros", + "ad_group_criterion.quality_info.quality_score", + "metrics.impressions", + "metrics.clicks", + "metrics.ctr", + "metrics.cost_micros", + "metrics.conversions", + "metrics.cost_per_conversion", + "metrics.search_impression_share", + ] + + conditions = [ + f"segments.date BETWEEN '{start_date}' AND '{end_date}'", + "ad_group_criterion.type = 'KEYWORD'", + "ad_group_criterion.status != 'REMOVED'", + ] + if status != "ALL": + conditions.append(f"ad_group_criterion.status = '{status}'") + if campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + if ad_group_id: + conditions.append(f"ad_group.id = {ad_group_id}") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM keyword_view" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY metrics.cost_micros DESC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_keyword_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + crit = row.ad_group_criterion + m = row.metrics + qs = crit.quality_info.quality_score + rows.append({ + "campaign_name": row.campaign.name, + "ad_group_name": row.ad_group.name, + "keyword": crit.keyword.text, + "match_type": crit.keyword.match_type.name, + "status": crit.status.name, + "cpc_bid_micros": crit.cpc_bid_micros, + "quality_score": qs if qs > 0 else None, + "impressions": m.impressions, + "clicks": m.clicks, + "ctr": round(m.ctr, 4), + "cost_micros": m.cost_micros, + "conversions": round(m.conversions, 2), + "cost_per_conversion": round(m.cost_per_conversion, 2), + "search_impression_share": round(m.search_impression_share, 4), + }) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) diff --git a/ads_mcp/tools/product_catalog_report.py b/ads_mcp/tools/product_catalog_report.py new file mode 100644 index 0000000..91dace9 --- /dev/null +++ b/ads_mcp/tools/product_catalog_report.py @@ -0,0 +1,114 @@ +"""Product catalog report — shopping performance by product dimension.""" + +from typing import Any, Dict, List, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_product_catalog_report( + customer_id: str, + start_date: str, + end_date: str, + campaign_id: Optional[str] = None, + limit: int = 500, +) -> List[Dict[str, Any]]: + """Returns Shopping campaign performance broken down by product attributes + (brand, category, product type, custom labels, item ID). + + Each row is one product / product group combination with its performance metrics + and impression share data. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + campaign_id: Optional. Filter to a single Shopping campaign. + limit: Maximum rows to return (default 500). + + Returns: + List of rows with: + - campaign_name, campaign_id + - product_brand, product_type, product_category, product_item_id + - custom_label_0 through custom_label_4 + - impressions, clicks, ctr, cost_micros, conversions + - search_impression_share: IS for this product + - search_budget_lost_impression_share + - search_rank_lost_impression_share + """ + fields = [ + "campaign.name", + "campaign.id", + "segments.product_brand", + "segments.product_type_l1", + "segments.product_category_level1", + "segments.product_item_id", + "segments.product_custom_attribute0", + "segments.product_custom_attribute1", + "segments.product_custom_attribute2", + "metrics.impressions", + "metrics.clicks", + "metrics.ctr", + "metrics.cost_micros", + "metrics.conversions", + "metrics.conversions_value", + "metrics.cost_per_conversion", + "metrics.search_impression_share", + "metrics.search_budget_lost_impression_share", + "metrics.search_rank_lost_impression_share", + ] + + conditions = [ + f"segments.date BETWEEN '{start_date}' AND '{end_date}'", + "metrics.impressions > 0", + ] + if campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM shopping_performance_view" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY metrics.cost_micros DESC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_product_catalog_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + m = row.metrics + seg = row.segments + rows.append({ + "campaign_name": row.campaign.name, + "campaign_id": str(row.campaign.id), + "product_brand": seg.product_brand, + "product_type": seg.product_type_l1, + "product_category": seg.product_category_level1, + "product_item_id": seg.product_item_id, + "custom_label_0": seg.product_custom_attribute0, + "custom_label_1": seg.product_custom_attribute1, + "custom_label_2": seg.product_custom_attribute2, + "impressions": m.impressions, + "clicks": m.clicks, + "ctr": round(m.ctr, 4), + "cost_micros": m.cost_micros, + "conversions": round(m.conversions, 2), + "conversions_value": round(m.conversions_value, 2), + "cost_per_conversion": round(m.cost_per_conversion, 2), + "search_impression_share": round(m.search_impression_share, 4), + "search_budget_lost_impression_share": round(m.search_budget_lost_impression_share, 4), + "search_rank_lost_impression_share": round(m.search_rank_lost_impression_share, 4), + }) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) diff --git a/ads_mcp/tools/reach_frequency_report.py b/ads_mcp/tools/reach_frequency_report.py new file mode 100644 index 0000000..b237e38 --- /dev/null +++ b/ads_mcp/tools/reach_frequency_report.py @@ -0,0 +1,185 @@ +"""Reach & Frequency report and audience size / overlap math for TAM estimation.""" + +from typing import Any, Dict, List, Optional +from ads_mcp.coordinator import mcp +from mcp.types import ToolAnnotations +import ads_mcp.utils as utils +from google.ads.googleads.errors import GoogleAdsException +from fastmcp.exceptions import ToolError + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_reach_frequency_report( + customer_id: str, + start_date: str, + end_date: str, + campaign_id: Optional[str] = None, + limit: int = 200, +) -> List[Dict[str, Any]]: + """Returns impressions and frequency metrics per campaign. + + True reach (unique users) is only available for Video and Display campaigns + in Google Ads. For Search campaigns this tool returns impressions and + average frequency derived from impression data instead. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + start_date: Start date in YYYY-MM-DD format. + end_date: End date in YYYY-MM-DD format. + campaign_id: Optional. Filter to a single campaign. + limit: Maximum rows to return (default 200). + + Returns: + List of rows with: + - campaign_name, campaign_id + - channel_type: SEARCH, DISPLAY, VIDEO, etc. + - impressions + - clicks, ctr + - cost_micros + - conversions + Note: Frequency is not a GAQL metric for Search campaigns. + For Video/Display, use the reach_plan_cell resource for forecasting. + """ + fields = [ + "campaign.name", + "campaign.id", + "campaign.advertising_channel_type", + "campaign.status", + "metrics.impressions", + "metrics.clicks", + "metrics.ctr", + "metrics.cost_micros", + "metrics.conversions", + "metrics.cost_per_conversion", + ] + + conditions = [ + f"segments.date BETWEEN '{start_date}' AND '{end_date}'", + "metrics.impressions > 0", + "campaign.status != 'REMOVED'", + ] + if campaign_id: + conditions.append(f"campaign.id = {campaign_id}") + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM campaign" + f" WHERE {' AND '.join(conditions)}" + f" ORDER BY metrics.impressions DESC" + f" LIMIT {limit}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_reach_frequency_report query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in response: + for row in batch.results: + m = row.metrics + rows.append({ + "campaign_name": row.campaign.name, + "campaign_id": str(row.campaign.id), + "channel_type": row.campaign.advertising_channel_type.name, + "status": row.campaign.status.name, + "impressions": m.impressions, + "clicks": m.clicks, + "ctr": round(m.ctr, 4), + "cost_micros": m.cost_micros, + "conversions": round(m.conversions, 2), + "cost_per_conversion": round(m.cost_per_conversion, 2), + "note": "Frequency (unique reach) is only available for Video/Display via reach_plan_cell resource.", + }) + return rows + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) + + +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) +def get_audience_overlap_estimate( + customer_id: str, + audience_ids: List[str], +) -> Dict[str, Any]: + """Returns the size of each audience (user list) and an estimated union size. + + Google Ads does not expose true audience overlap data via GAQL. This tool + fetches individual audience sizes and computes a conservative union estimate + using the inclusion-exclusion principle with an assumed 20% pairwise overlap. + Use this as a rough TAM proxy — not an exact figure. + + Args: + customer_id: Client account ID, digits only (e.g. "1635583349"). + audience_ids: List of user_list resource IDs (digits only, e.g. ["123456", "789012"]). + + Returns: + - audiences: list of {id, name, size_for_search, size_range_for_search, eligible_for_search} + - estimated_union_search: rough estimate of combined unique audience for search + - note: explanation of the estimation method + """ + fields = [ + "user_list.id", + "user_list.name", + "user_list.size_for_search", + "user_list.size_range_for_search", + "user_list.size_for_display", + "user_list.size_range_for_display", + "user_list.eligible_for_search", + "user_list.type", + ] + + id_list = ", ".join(audience_ids) + conditions = [f"user_list.id IN ({id_list})"] + + query = ( + f"SELECT {', '.join(fields)}" + f" FROM user_list" + f" WHERE {' AND '.join(conditions)}" + f" PARAMETERS omit_unselected_resource_names=true" + ) + + utils.logger.info(f"get_audience_overlap_estimate query: {query}") + ga_service = utils.get_googleads_service("GoogleAdsService") + + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + audiences = [] + total_size = 0 + for batch in response: + for row in batch.results: + ul = row.user_list + size = ul.size_for_search + audiences.append({ + "id": str(ul.id), + "name": ul.name, + "type": ul.type_.name, + "size_for_search": size, + "size_range_for_search": ul.size_range_for_search.name, + "size_for_display": ul.size_for_display, + "size_range_for_display": ul.size_range_for_display.name, + "eligible_for_search": ul.eligible_for_search, + }) + total_size += size + + # Conservative union estimate: subtract 20% assumed pairwise overlap per additional audience + n = len(audiences) + if n <= 1: + estimated_union = audiences[0]["size_for_search"] if audiences else 0 + else: + overlap_factor = 1 - (0.20 * (n - 1) / n) + estimated_union = int(total_size * max(overlap_factor, 0.5)) + + return { + "audiences": audiences, + "estimated_union_search": estimated_union, + "note": ( + "Union is estimated using 20% assumed pairwise overlap. " + "Google Ads does not expose true overlap data via GAQL. " + "Treat this as a rough upper bound for TAM estimation." + ), + } + except GoogleAdsException as ex: + error_msgs = [e.message for e in ex.failure.errors] + raise ToolError(f"Request ID: {ex.request_id}\n" + "\n".join(error_msgs)) From 845c0c9f27f5a557ebb9cf84b90e069bc2a6e3bb Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Tue, 12 May 2026 19:10:47 +0530 Subject: [PATCH 7/8] updated the functions name specific to google --- ads_mcp/tools/auction_insights_report.py | 4 ++-- ads_mcp/tools/audience_analysis_report.py | 4 ++-- ads_mcp/tools/change_history_report.py | 4 ++-- ads_mcp/tools/impression_share_report.py | 4 ++-- ads_mcp/tools/keyword_quality_report.py | 4 ++-- ads_mcp/tools/keyword_report.py | 4 ++-- ads_mcp/tools/product_catalog_report.py | 4 ++-- ads_mcp/tools/reach_frequency_report.py | 8 ++++---- ads_mcp/tools/search_term_report.py | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/ads_mcp/tools/auction_insights_report.py b/ads_mcp/tools/auction_insights_report.py index 5940e4e..789c06d 100644 --- a/ads_mcp/tools/auction_insights_report.py +++ b/ads_mcp/tools/auction_insights_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_auction_insights_report( +def get_google_auction_insights_report( customer_id: str, start_date: str, end_date: str, @@ -95,7 +95,7 @@ def get_auction_insights_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_auction_insights_report query: {query}") + utils.logger.info(f"get_google_auction_insights_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/audience_analysis_report.py b/ads_mcp/tools/audience_analysis_report.py index 251d2ae..67b542e 100644 --- a/ads_mcp/tools/audience_analysis_report.py +++ b/ads_mcp/tools/audience_analysis_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_audience_analysis_report( +def get_google_audience_analysis_report( customer_id: str, start_date: str, end_date: str, @@ -78,7 +78,7 @@ def get_audience_analysis_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_audience_analysis_report query: {query}") + utils.logger.info(f"get_google_audience_analysis_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/change_history_report.py b/ads_mcp/tools/change_history_report.py index 0ec1f5e..837e3b9 100644 --- a/ads_mcp/tools/change_history_report.py +++ b/ads_mcp/tools/change_history_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_change_history_report( +def get_google_change_history_report( customer_id: str, start_date: str, end_date: str, @@ -77,7 +77,7 @@ def get_change_history_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_change_history_report query: {query}") + utils.logger.info(f"get_google_change_history_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/impression_share_report.py b/ads_mcp/tools/impression_share_report.py index eb76537..053339a 100644 --- a/ads_mcp/tools/impression_share_report.py +++ b/ads_mcp/tools/impression_share_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_impression_share_report( +def get_google_impression_share_report( customer_id: str, start_date: str, end_date: str, @@ -134,7 +134,7 @@ def get_impression_share_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_impression_share_report query: {query}") + utils.logger.info(f"get_google_impression_share_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/keyword_quality_report.py b/ads_mcp/tools/keyword_quality_report.py index a9810cc..c60dea1 100644 --- a/ads_mcp/tools/keyword_quality_report.py +++ b/ads_mcp/tools/keyword_quality_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_keyword_quality_report( +def get_google_keyword_quality_report( customer_id: str, start_date: str, end_date: str, @@ -96,7 +96,7 @@ def get_keyword_quality_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_keyword_quality_report query: {query}") + utils.logger.info(f"get_google_keyword_quality_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/keyword_report.py b/ads_mcp/tools/keyword_report.py index 5db0349..b31e29e 100644 --- a/ads_mcp/tools/keyword_report.py +++ b/ads_mcp/tools/keyword_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_keyword_report( +def get_google_keyword_report( customer_id: str, start_date: str, end_date: str, @@ -80,7 +80,7 @@ def get_keyword_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_keyword_report query: {query}") + utils.logger.info(f"get_google_keyword_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/product_catalog_report.py b/ads_mcp/tools/product_catalog_report.py index 91dace9..6e66a00 100644 --- a/ads_mcp/tools/product_catalog_report.py +++ b/ads_mcp/tools/product_catalog_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_product_catalog_report( +def get_google_product_catalog_report( customer_id: str, start_date: str, end_date: str, @@ -77,7 +77,7 @@ def get_product_catalog_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_product_catalog_report query: {query}") + utils.logger.info(f"get_google_product_catalog_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/reach_frequency_report.py b/ads_mcp/tools/reach_frequency_report.py index b237e38..2568acd 100644 --- a/ads_mcp/tools/reach_frequency_report.py +++ b/ads_mcp/tools/reach_frequency_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_reach_frequency_report( +def get_google_reach_frequency_report( customer_id: str, start_date: str, end_date: str, @@ -70,7 +70,7 @@ def get_reach_frequency_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_reach_frequency_report query: {query}") + utils.logger.info(f"get_google_reach_frequency_report query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: @@ -99,7 +99,7 @@ def get_reach_frequency_report( @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_audience_overlap_estimate( +def get_google_audience_overlap_estimate( customer_id: str, audience_ids: List[str], ) -> Dict[str, Any]: @@ -140,7 +140,7 @@ def get_audience_overlap_estimate( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_audience_overlap_estimate query: {query}") + utils.logger.info(f"get_google_audience_overlap_estimate query: {query}") ga_service = utils.get_googleads_service("GoogleAdsService") try: diff --git a/ads_mcp/tools/search_term_report.py b/ads_mcp/tools/search_term_report.py index e7023ef..4d159e3 100644 --- a/ads_mcp/tools/search_term_report.py +++ b/ads_mcp/tools/search_term_report.py @@ -9,7 +9,7 @@ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) -def get_search_term_report( +def get_google_search_term_report( customer_id: str, start_date: str, end_date: str, @@ -83,7 +83,7 @@ def get_search_term_report( f" PARAMETERS omit_unselected_resource_names=true" ) - utils.logger.info(f"get_search_term_report query: {query}") + utils.logger.info(f"get_google_search_term_report query: {query}") try: response = ga_service.search_stream(customer_id=customer_id, query=query) From 947d07ec86ddd2c6fe5e3b408967d494e01ffe63 Mon Sep 17 00:00:00 2001 From: Varun Bhayana Date: Wed, 27 May 2026 14:43:59 +0530 Subject: [PATCH 8/8] delete .env.local and sh script to run --- .env.local | 6 ------ run-http-local.sh | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) delete mode 100644 .env.local create mode 100755 run-http-local.sh diff --git a/.env.local b/.env.local deleted file mode 100644 index 87cbe54..0000000 --- a/.env.local +++ /dev/null @@ -1,6 +0,0 @@ -GOOGLE_PROJECT_ID=your-gcp-project-id -GOOGLE_ADS_DEVELOPER_TOKEN=mM23ZIOAiOldJD-oEItRGw -GOOGLE_APPLICATION_CREDENTIALS=/Users/varunbhayana/Desktop/projects/google-ads-mcp/.gcloud/application_default_credentials.json -# Optional: if you access the target account through a manager account -GOOGLE_ADS_LOGIN_CUSTOMER_ID=506-112-2756 - diff --git a/run-http-local.sh b/run-http-local.sh new file mode 100755 index 0000000..4ea30ea --- /dev/null +++ b/run-http-local.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ROOT_DIR}/.env.docker" +VENV_PYTHON="${ROOT_DIR}/.venv/bin/python" + +DEFAULT_ADC_PATH="/Users/varunbhayana/Desktop/projects/google-ads-mcp/.gcloud/application_default_credentials.json" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "Missing ${ENV_FILE}" + exit 1 +fi + +if [[ ! -x "${VENV_PYTHON}" ]]; then + echo "Missing ${VENV_PYTHON}" + echo "Create the virtualenv and install dependencies first." + exit 1 +fi + +set -a +source "${ENV_FILE}" +set +a + +export GOOGLE_APPLICATION_CREDENTIALS="${GOOGLE_APPLICATION_CREDENTIALS:-${DEFAULT_ADC_PATH}}" +export MCP_LOCAL_HTTP="${MCP_LOCAL_HTTP:-true}" +export PORT="${PORT:-8080}" + +if [[ ! -f "${GOOGLE_APPLICATION_CREDENTIALS}" ]]; then + echo "ADC file not found: ${GOOGLE_APPLICATION_CREDENTIALS}" + exit 1 +fi + +echo "Starting Google Ads MCP on http://127.0.0.1:${PORT}/mcp" +echo "Using env file: ${ENV_FILE}" +echo "Using ADC file: ${GOOGLE_APPLICATION_CREDENTIALS}" + +exec "${VENV_PYTHON}" -m ads_mcp.server