Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ credentials.json
.envrc
.claude
dogfood-output/

__pycache__/
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `link_asset_to_asset_group`: link an existing asset to a Performance Max
asset group via `assetGroupAssetOperation.create`. `upload_image_asset` only
ever created the asset — nothing attached it, so uploaded images sat unused
in the account's asset library. A PMax asset group without a
`MARKETING_IMAGE`, `SQUARE_MARKETING_IMAGE` and `LOGO` is "Not eligible" and
never serves, so this is the step that makes an asset group deliverable. The
field type is validated client-side against the 11 types PMax accepts, since
the API error for a bad one is opaque.
- `add_asset_group_signal`: add search themes and/or audience signals to a
Performance Max asset group via `assetGroupSignalOperation.create`. PMax does
not take audiences as campaign criteria — `add_audience_targeting` writes a
`campaignCriterion`, which the API rejects for a PMax campaign. Accepts
search themes, audience IDs, or both, and enforces the 80-character search
theme limit before the request goes out.
- `scripts/gads_mutate.py`: send a `googleAds:mutate` payload and print
Google's full error tree, with `--check` for `validateOnly`. The server
collapses API failures to "Request contains an invalid argument", which hides
the field-level reason that actually explains a rejected mutate.

### Fixed

- `create_pmax_campaign` now sets `campaign_budget.explicitly_shared = false`.
Budgets default to shared server-side, and Performance Max rejects a shared
budget with `BIDDING_STRATEGY_TYPE_INCOMPATIBLE_WITH_SHARED_BUDGET`.
`draft_campaign` got this fix in 5670d2d; the PMax path was missed.
- `create_pmax_campaign` now sets
`campaign.contains_eu_political_advertising = DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING`.
The field is required on every campaign create, and omitting it failed the
entire mutate with `fieldError=REQUIRED`.

Together these two meant `create_pmax_campaign` could not create any
Performance Max campaign — every call failed at `confirm_and_apply`.

- `set_campaign_geo_target_type`: set a campaign's
`campaign.geo_target_type_setting` — `positive_geo_target_type` and/or
`negative_geo_target_type` — via a `campaignOperation.update` with a matching
Expand Down
129 changes: 129 additions & 0 deletions scripts/gads_mutate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Run a googleAds:mutate request and print Google's full error detail.

The MCP server collapses API failures to "Request contains an invalid argument",
which hides the field-level reason. This sends the same payload and prints the
whole error tree, so a rejected mutate can actually be diagnosed.

Usage:
scripts/gads_mutate.py operations.json # apply
scripts/gads_mutate.py operations.json --check # validate_only, changes nothing

operations.json is the JSON array that goes in "mutateOperations".
Reads .env from the repo root (same file scripts/mcp-env.sh uses).
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
API = "https://googleads.googleapis.com/v23"


def load_env():
env = {}
path = os.environ.get("MCP_GOOGLE_ADS_ENV", os.path.join(REPO, ".env"))
with open(path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
env[k.strip()] = v.strip().strip('"').strip("'")
return env


def access_token(env):
with open(os.path.expanduser(env["GOOGLE_ADS_CREDENTIALS_PATH"])) as fh:
cred = json.load(fh)["installed"]
with open(os.path.expanduser(env["GOOGLE_ADS_TOKEN_PATH"])) as fh:
refresh = json.load(fh)["refresh_token"]
body = urllib.parse.urlencode({
"client_id": cred["client_id"],
"client_secret": cred["client_secret"],
"refresh_token": refresh,
"grant_type": "refresh_token",
}).encode()
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=body)
with urllib.request.urlopen(req) as resp:
return json.load(resp)["access_token"]


def headers(env, token):
h = {
"Authorization": f"Bearer {token}",
"developer-token": env["GOOGLE_ADS_DEVELOPER_TOKEN"],
"Content-Type": "application/json",
}
login = env.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "").replace("-", "")
if login:
h["login-customer-id"] = login
return h


def mutate(env, token, operations, validate_only=False, customer_id=None):
cust = (customer_id or env["GOOGLE_ADS_CUSTOMER_ID"]).replace("-", "")
payload = {"mutateOperations": operations, "validateOnly": validate_only}
req = urllib.request.Request(
f"{API}/customers/{cust}/googleAds:mutate",
data=json.dumps(payload).encode(),
headers=headers(env, token),
)
try:
with urllib.request.urlopen(req) as resp:
return json.load(resp), None
except urllib.error.HTTPError as e:
return None, json.load(e)


def describe(err):
"""Flatten Google's nested error tree into readable lines."""
lines = []
for detail in err.get("error", {}).get("details", []):
for item in detail.get("errors", []):
code = item.get("errorCode", {})
code_str = ", ".join(f"{k}={v}" for k, v in code.items())
loc = item.get("location", {})
path = ".".join(
str(f.get("fieldName", f.get("index", "")))
for f in loc.get("fieldPathElements", [])
)
lines.append(f" [{code_str}] {item.get('message', '')}")
if path:
lines.append(f" at: {path}")
if not lines:
lines.append(f" {err.get('error', {}).get('message', json.dumps(err))}")
return "\n".join(lines)


def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
validate_only = "--check" in sys.argv
if not args:
print(__doc__)
return 2

with open(args[0]) as fh:
operations = json.load(fh)

env = load_env()
token = access_token(env)
cid = os.environ.get("GADS_CUSTOMER_ID")
result, err = mutate(env, token, operations, validate_only, cid)

if err:
print(f"FAILED ({'validate' if validate_only else 'apply'}):", file=sys.stderr)
print(describe(err), file=sys.stderr)
return 1

print(f"OK ({'validated' if validate_only else 'applied'}) "
f"{len(operations)} operation(s)")
print(json.dumps(result, indent=2))
return 0


if __name__ == "__main__":
sys.exit(main())
81 changes: 81 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,34 @@ pub struct UploadTextAssetToolParams {
pub text_content: String,
}

/// Parameters for linking an asset to a Performance Max asset group.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct LinkAssetToAssetGroupToolParams {
/// Customer ID (e.g. 123-456-7890). Defaults to configured customer_id.
pub customer_id: Option<String>,
/// The asset group ID to link the asset to.
pub asset_group_id: String,
/// The asset ID to link (from upload_image_asset / upload_text_asset).
pub asset_id: String,
/// Field type: MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE,
/// LOGO, LANDSCAPE_LOGO, YOUTUBE_VIDEO, HEADLINE, DESCRIPTION, LONG_HEADLINE,
/// BUSINESS_NAME, CALL_TO_ACTION_SELECTION.
pub field_type: String,
}

/// Parameters for adding signals to a Performance Max asset group.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct AddAssetGroupSignalToolParams {
/// Customer ID (e.g. 123-456-7890). Defaults to configured customer_id.
pub customer_id: Option<String>,
/// The asset group ID to add signals to.
pub asset_group_id: String,
/// Search themes (max 80 chars each) telling PMax what queries to look for.
pub search_themes: Option<Vec<String>>,
/// Audience IDs referencing customers/{cid}/audiences/{id}.
pub audience_ids: Option<Vec<String>>,
}

/// Parameters for setting campaign ad schedule.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SetCampaignScheduleToolParams {
Expand Down Expand Up @@ -1403,6 +1431,59 @@ impl GoogleAdsMcp {
}
}

#[tool(
description = "Link an uploaded asset to a Performance Max asset group. Required for images \
to serve — upload_image_asset alone leaves the asset unattached. Returns a preview."
)]
async fn link_asset_to_asset_group(
&self,
Parameters(params): Parameters<LinkAssetToAssetGroupToolParams>,
) -> String {
if let Some(err) = self.check_write_allowed() {
return err;
}
let cid = self.resolve_customer_id(params.customer_id.as_deref());
let config = self.config.clone();

match tools::assets::link_asset_to_asset_group(
&config,
&cid,
&params.asset_group_id,
&params.asset_id,
&params.field_type,
) {
Ok(preview) => preview.to_string(),
Err(e) => serde_json::json!({"error": e.to_string()}).to_string(),
}
}

#[tool(
description = "Add search themes and/or audience signals to a Performance Max asset group. \
Use this for PMax audiences — add_audience_targeting writes a campaign \
criterion, which PMax rejects. Returns a preview."
)]
async fn add_asset_group_signal(
&self,
Parameters(params): Parameters<AddAssetGroupSignalToolParams>,
) -> String {
if let Some(err) = self.check_write_allowed() {
return err;
}
let cid = self.resolve_customer_id(params.customer_id.as_deref());
let config = self.config.clone();

match tools::audiences::add_asset_group_signal(
&config,
&cid,
&params.asset_group_id,
&params.search_themes.unwrap_or_default(),
&params.audience_ids.unwrap_or_default(),
) {
Ok(preview) => preview.to_string(),
Err(e) => serde_json::json!({"error": e.to_string()}).to_string(),
}
}

// ── Phase 5: Scheduling ─────────────────────────────────────────────

#[tool(
Expand Down
Loading