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/.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 new file mode 100644 index 0000000..9503ef9 --- /dev/null +++ b/LOCAL_DEVELOPMENT.md @@ -0,0 +1,238 @@ +# Local Development Setup + +This guide documents the exact local setup to run this repository from source on macOS with `zsh`. + +## What Lives in This Repo (Not Committed) + +| 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) | + +--- + +## Prerequisites + +- 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) + +--- + +## Step 1 — Create the 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/python -c "import ads_mcp.server; print('ok')" +``` + +--- + +## Step 2 — Authenticate with Application Default Credentials + +Use the local `gcloud` wrapper so credentials stay inside `.gcloud/` instead of your global `~/.config/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 complete, the credentials file is at: + +``` +.gcloud/application_default_credentials.json +``` + +--- + +## Step 3 — Configure `.env.local` + +Copy the template: + +```bash +cp .env.local.example .env.local +``` + +Fill in `.env.local`: + +```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 + +# 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 +``` + +--- + +## Step 4 — Start the Server + +```bash +./run-local.sh +``` + +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. + +--- + +## Step 5 — Configure Claude Code (`.mcp.json`) + +Create `.mcp.json` at the repo root. This file is gitignored because it contains absolute local paths. + +```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" + } + } + } +} +``` + +Replace `/absolute/path/to/repo` with the actual path on your machine. + +After creating this file, restart Claude Code. The MCP server starts automatically when Claude Code loads the project. + +--- + +## Account Structure + +The credential authenticates as a top-level MCC. Queries must target a **leaf client account** (a non-manager account inside the MCC): + +- `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 + +To list all leaf accounts under the MCC, use the `search` tool with: + +``` +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"] +``` + +Filter for rows where `customer_client.manager = false` — those are the queryable leaf accounts. + +--- + +## Available MCP Tools + +### Built-in (generic) + +| 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 | + +### Custom (curated) + +| 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: Run `get_search_term_report` + +```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, +) +``` + +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`. + +`status = NONE` means the term is not yet added as a keyword or negative — these are the candidates to review for negative keyword additions. + +--- + +## Troubleshooting + +**`Missing .env.local`** + +```bash +cp .env.local.example .env.local +``` + +**`GOOGLE_ADS_DEVELOPER_TOKEN environment variable not set`** + +Fill in `GOOGLE_ADS_DEVELOPER_TOKEN` in `.env.local`. + +**`google-ads-mcp: command not found`** + +```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 have production access. See the Google Ads access levels documentation. + +**ADC or auth errors** + +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_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` diff --git a/MCP_CHANGE_PLAN.md b/MCP_CHANGE_PLAN.md new file mode 100644 index 0000000..d545f3e --- /dev/null +++ b/MCP_CHANGE_PLAN.md @@ -0,0 +1,142 @@ +# MCP Change Plan for Required Google Ads Reporting + +## Goal + +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 & overlap math +- Auction Insights +- Keywords +- Product catalog +- TAM / audience size + +--- + +## Validated Capability Matrix (2026-05-06) + +All items below were validated against the live Google Ads API using `get_resource_metadata` and real account data. + +| # | 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. | + +--- + +## Generic Reporting to Block + +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: + +| 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 | + +**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. + +--- + +## Account Structure + +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). + +- `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: + +``` +SELECT customer_client.client_customer, customer_client.level, + customer_client.manager, customer_client.descriptive_name +FROM customer_client +WHERE customer_client.level <= 2 +``` + +--- + +## Code Changes + +### Done + +**`ads_mcp/tools/search_term_report.py`** — new file + +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. + +**`ads_mcp/server.py`** — one line added + +```python +from ads_mcp.tools import search, core, get_resource_metadata, search_term_report # noqa: F401 +``` + +**`.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/.venv/bin/google-ads-mcp", + "env": { + "GOOGLE_APPLICATION_CREDENTIALS": "...", + "GOOGLE_ADS_DEVELOPER_TOKEN": "...", + "GOOGLE_ADS_LOGIN_CUSTOMER_ID": "5061122756" + } + } + } +} +``` + +### Remaining — Custom Tools + +| 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 | + +### Remaining — Policy Layer + +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. + +--- + +## Relevant Files + +| 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..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 # 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, @@ -35,10 +48,15 @@ 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: + # 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..789c06d --- /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_google_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_google_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..67b542e --- /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_google_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_google_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..837e3b9 --- /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_google_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_google_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/impression_share_report.py b/ads_mcp/tools/impression_share_report.py new file mode 100644 index 0000000..053339a --- /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_google_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_google_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..c60dea1 --- /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_google_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_google_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)) diff --git a/ads_mcp/tools/keyword_report.py b/ads_mcp/tools/keyword_report.py new file mode 100644 index 0000000..b31e29e --- /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_google_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_google_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..6e66a00 --- /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_google_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_google_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..2568acd --- /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_google_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_google_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_google_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_google_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)) diff --git a/ads_mcp/tools/search_term_report.py b/ads_mcp/tools/search_term_report.py new file mode 100644 index 0000000..4d159e3 --- /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_google_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_google_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 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 "$@" 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