mcp-google-ads 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -28,11 +28,22 @@ import {
28
28
  normalizeUpdateAssetUrlsArgs,
29
29
  normalizePauseAssetLinksArgs,
30
30
  buildUpdateUrlsDryRun,
31
- buildPauseLinksDryRun
31
+ buildPauseLinksDryRun,
32
+ normalizeCreateSitelinkArgs,
33
+ buildCreateSitelinkDryRun,
34
+ normalizeReplaceSitelinkArgs,
35
+ buildReplaceSitelinkDryRun
32
36
  } from "./assetHelpers.js";
33
37
  import {
34
38
  buildCampaignCreatePayload
35
39
  } from "./campaignBuilder.js";
40
+ import {
41
+ buildExperimentPayload,
42
+ buildControlArmPayload,
43
+ buildTreatmentArmPayload,
44
+ experimentIdFromResourceName,
45
+ campaignIdFromResourceName
46
+ } from "./experimentBuilder.js";
36
47
  import {
37
48
  buildAdGroupCreatePayload
38
49
  } from "./adGroupBuilder.js";
@@ -45,6 +56,7 @@ import {
45
56
  isDemandGenAdGroup
46
57
  } from "./validateDemandGenAd.js";
47
58
  import { GoogleAdsApi, enums } from "google-ads-api";
59
+ import { buildAdGroupAdResourceName } from "./resourceNames.js";
48
60
  import { readFileSync, existsSync } from "fs";
49
61
  import v8 from "v8";
50
62
  const __cliPkg = JSON.parse(readFileSync(join(__moduleDir, "..", "package.json"), "utf-8"));
@@ -434,7 +446,7 @@ class GoogleAdsManager {
434
446
  const mm = String(now.getMonth() + 1).padStart(2, "0");
435
447
  const dd = String(now.getDate()).padStart(2, "0");
436
448
  const yy = String(now.getFullYear()).slice(-2);
437
- return `Claude-${mm}-${dd}-${yy}`;
449
+ return `claude-${mm}-${dd}-${yy}`;
438
450
  }
439
451
  // Apply today's Claude-MM-DD-YY label to newly created assets.
440
452
  // Best-effort: log and swallow errors so creation never fails because of labeling.
@@ -568,13 +580,25 @@ class GoogleAdsManager {
568
580
  "createCampaign.budget"
569
581
  );
570
582
  const budgetResourceName = budgetResult.results[0].resource_name;
571
- const campaignResult = await withResilience(
572
- () => customer.campaigns.create([{
573
- ...plan.campaign,
574
- campaign_budget: budgetResourceName
575
- }]),
576
- "createCampaign"
577
- );
583
+ const campaignPayload = { ...plan.campaign, campaign_budget: budgetResourceName };
584
+ const isDemandGen = plan.campaign.advertising_channel_type === 14;
585
+ let campaignResult;
586
+ if (isDemandGen) {
587
+ const mutateResp = await withResilience(
588
+ () => customer.mutateResources([
589
+ { entity: "campaign", operation: "create", resource: campaignPayload }
590
+ ]),
591
+ "createCampaign"
592
+ );
593
+ campaignResult = {
594
+ results: (mutateResp.mutate_operation_responses || []).map((r) => r.campaign_result).filter(Boolean)
595
+ };
596
+ } else {
597
+ campaignResult = await withResilience(
598
+ () => customer.campaigns.create([campaignPayload]),
599
+ "createCampaign"
600
+ );
601
+ }
578
602
  const campaignRNs = (campaignResult.results || []).map((r) => r.resource_name).filter(Boolean);
579
603
  if (plan.criteria.length > 0 && campaignRNs.length > 0) {
580
604
  const campaignResourceName = campaignRNs[0];
@@ -819,6 +843,18 @@ class GoogleAdsManager {
819
843
  const result = await withResilience(() => customer.adGroupCriteria.update(operations), "pauseKeywords");
820
844
  return result;
821
845
  }
846
+ // Enable keywords (auto-labels with today's Claude-MM-DD-YY + any custom labels)
847
+ async enableKeywords(customerId, criterionResourceNames, labels) {
848
+ const customer = this.getCustomer(customerId);
849
+ const operations = criterionResourceNames.map((rn) => ({
850
+ resource_name: rn,
851
+ status: enums.AdGroupCriterionStatus.ENABLED
852
+ }));
853
+ const result = await withResilience(() => customer.adGroupCriteria.update(operations), "enableKeywords");
854
+ await this.autoLabelCreated(customerId, criterionResourceNames, "keyword");
855
+ await this.applyCustomLabels(customerId, criterionResourceNames, "keyword", labels);
856
+ return result;
857
+ }
822
858
  // Create a new shared negative keyword list at account level
823
859
  async createSharedSet(customerId, name) {
824
860
  const customer = this.getCustomer(customerId);
@@ -888,6 +924,44 @@ class GoogleAdsManager {
888
924
  const result = await withResilience(() => customer.campaignCriteria.create(criteria), "addCampaignNegativeKeywords");
889
925
  return result;
890
926
  }
927
+ // Add location (geo) targeting criteria at the AD GROUP level.
928
+ // Required for Demand Gen campaigns — DG sets location at ad group, not campaign.
929
+ // Uses string type "LOCATION" (integer 77) — not in v23 enum map, same pattern
930
+ // as DEMAND_GEN_MULTI_ASSET_AD_GROUP and EuPoliticalAdvertising fixes.
931
+ async setAdGroupLocationTargeting(customerId, adGroupId, geoTargetIds) {
932
+ const customer = this.getCustomer(customerId);
933
+ const cleanId = customerId.replace(/-/g, "");
934
+ const adGroupRN = `customers/${cleanId}/adGroups/${adGroupId}`;
935
+ const criteria = geoTargetIds.map((id) => ({
936
+ ad_group: adGroupRN,
937
+ type: "LOCATION",
938
+ location: { geo_target_constant: `geoTargetConstants/${id}` }
939
+ }));
940
+ const result = await withResilience(
941
+ () => customer.adGroupCriteria.create(criteria),
942
+ "setAdGroupLocationTargeting"
943
+ );
944
+ return result;
945
+ }
946
+ // Add location (geo) targeting criteria to an existing campaign.
947
+ // Uses mutateResources (not campaignCriteria.create) because the v23 typed
948
+ // path doesn't recognize the `location` criterion field — same pattern as
949
+ // DEMAND_GEN campaign/ad-group creation.
950
+ async setCampaignLocationTargeting(customerId, campaignId, geoTargetIds) {
951
+ const customer = this.getCustomer(customerId);
952
+ const cleanId = customerId.replace(/-/g, "");
953
+ const campaignRN = `customers/${cleanId}/campaigns/${campaignId}`;
954
+ const criteria = geoTargetIds.map((id) => ({
955
+ campaign: campaignRN,
956
+ type: "LOCATION",
957
+ location: { geo_target_constant: `geoTargetConstants/${id}` }
958
+ }));
959
+ const result = await withResilience(
960
+ () => customer.campaignCriteria.create(criteria),
961
+ "setCampaignLocationTargeting"
962
+ );
963
+ return result;
964
+ }
891
965
  // Remove campaign-level negative keywords by resource name
892
966
  async removeCampaignNegativeKeywords(customerId, resourceNames) {
893
967
  const customer = this.getCustomer(customerId);
@@ -900,12 +974,43 @@ class GoogleAdsManager {
900
974
  const result = await withResilience(() => customer.adGroupCriteria.remove(resourceNames), "removeAdGroupNegativeKeywords");
901
975
  return result;
902
976
  }
977
+ // Resolve plain ad IDs to the composite ad_group_ad resource name
978
+ // (`customers/{cid}/adGroupAds/{adGroupId}~{adId}`) required by every
979
+ // adGroupAd mutation and label endpoint. Passing just `{adId}` is a
980
+ // Google Ads API error — this helper fixes that by looking up the
981
+ // adGroupId for each adId via GAQL in a single query.
982
+ async resolveAdGroupAdResourceNames(customerId, adIds) {
983
+ if (!adIds || adIds.length === 0) return [];
984
+ const cleanId = customerId.replace(/-/g, "");
985
+ const customer = this.getCustomer(customerId);
986
+ const sanitized = adIds.map((id) => sanitizeNumericId(String(id)));
987
+ const query = `SELECT ad_group.id, ad_group_ad.ad.id FROM ad_group_ad WHERE ad_group_ad.ad.id IN (${sanitized.join(",")})`;
988
+ const rows = await withResilience(
989
+ () => customer.query(query),
990
+ "resolveAdGroupAdResourceNames"
991
+ );
992
+ const resolved = [];
993
+ const foundIds = /* @__PURE__ */ new Set();
994
+ for (const row of rows) {
995
+ const adGroupId = row?.ad_group?.id;
996
+ const adId = row?.ad_group_ad?.ad?.id;
997
+ if (adGroupId != null && adId != null) {
998
+ resolved.push(buildAdGroupAdResourceName(cleanId, adGroupId, adId));
999
+ foundIds.add(String(adId));
1000
+ }
1001
+ }
1002
+ const missing = sanitized.filter((id) => !foundIds.has(id));
1003
+ if (missing.length > 0) {
1004
+ throw new Error(
1005
+ `Could not resolve ad_group_id for ${missing.length}/${adIds.length} ad IDs: ${missing.join(", ")}`
1006
+ );
1007
+ }
1008
+ return resolved;
1009
+ }
903
1010
  // Enable ads (auto-labels with today's Claude-MM-DD-YY + any custom labels)
904
1011
  async enableAds(customerId, adIds, labels) {
905
1012
  const customer = this.getCustomer(customerId);
906
- const resourceNames = adIds.map(
907
- (adId) => `customers/${customerId.replace(/-/g, "")}/adGroupAds/${adId}`
908
- );
1013
+ const resourceNames = await this.resolveAdGroupAdResourceNames(customerId, adIds);
909
1014
  const operations = resourceNames.map((rn) => ({
910
1015
  resource_name: rn,
911
1016
  status: enums.AdGroupAdStatus.ENABLED
@@ -976,9 +1081,7 @@ class GoogleAdsManager {
976
1081
  // Pause ads (auto-labels with today's Claude-MM-DD-YY + any custom labels)
977
1082
  async pauseAds(customerId, adIds, labels) {
978
1083
  const customer = this.getCustomer(customerId);
979
- const resourceNames = adIds.map(
980
- (adId) => `customers/${customerId.replace(/-/g, "")}/adGroupAds/${adId}`
981
- );
1084
+ const resourceNames = adIds.some((id) => id.startsWith("customers/")) ? adIds : await this.resolveAdGroupAdResourceNames(customerId, adIds);
982
1085
  const operations = resourceNames.map((rn) => ({
983
1086
  resource_name: rn,
984
1087
  status: enums.AdGroupAdStatus.PAUSED
@@ -1021,8 +1124,7 @@ class GoogleAdsManager {
1021
1124
  // Remove ads permanently. Reports on removed ads still work.
1022
1125
  async removeAds(customerId, adIds) {
1023
1126
  const customer = this.getCustomer(customerId);
1024
- const cleanId = customerId.replace(/-/g, "");
1025
- const resourceNames = adIds.map((id) => `customers/${cleanId}/adGroupAds/${id}`);
1127
+ const resourceNames = await this.resolveAdGroupAdResourceNames(customerId, adIds);
1026
1128
  return withResilience(() => customer.adGroupAds.remove(resourceNames), "removeAds");
1027
1129
  }
1028
1130
  // Remove ad groups permanently. Cascades to child ads.
@@ -1057,7 +1159,7 @@ class GoogleAdsManager {
1057
1159
  result.ad_groups_labeled = rns.length;
1058
1160
  }
1059
1161
  if (targets.adIds?.length) {
1060
- const rns = targets.adIds.map((id) => `customers/${cleanId}/adGroupAds/${id}`);
1162
+ const rns = await this.resolveAdGroupAdResourceNames(customerId, targets.adIds);
1061
1163
  await this.labelAdGroupAds(customerId, rns, labelRN);
1062
1164
  result.ads_labeled = rns.length;
1063
1165
  }
@@ -1211,7 +1313,7 @@ class GoogleAdsManager {
1211
1313
  else if (/\/adGroupAssets\//.test(rn)) byLevel.ad_group_asset.push(rn);
1212
1314
  else throw new Error(`Unrecognized asset-link resource name: ${rn}`);
1213
1315
  }
1214
- const PAUSED = 2;
1316
+ const PAUSED = enums.AssetLinkStatus.PAUSED;
1215
1317
  const result = { customer_id: cleanId, paused: { customer_asset: 0, campaign_asset: 0, ad_group_asset: 0 } };
1216
1318
  if (byLevel.customer_asset.length > 0) {
1217
1319
  const ops = byLevel.customer_asset.map((rn) => ({ resource_name: rn, status: PAUSED }));
@@ -1240,6 +1342,555 @@ class GoogleAdsManager {
1240
1342
  return result;
1241
1343
  }
1242
1344
  // ============================================
1345
+ // SITELINK CREATE + REPLACE
1346
+ // ============================================
1347
+ async createSitelink(customerId, args) {
1348
+ const customer = this.getCustomer(customerId);
1349
+ const cleanId = customerId.replace(/-/g, "");
1350
+ const sitelink = { link_text: args.link_text };
1351
+ if (args.description1) sitelink.description1 = args.description1;
1352
+ if (args.description2) sitelink.description2 = args.description2;
1353
+ const result = await withResilience(
1354
+ () => customer.assets.create([
1355
+ {
1356
+ type: enums.AssetType.SITELINK,
1357
+ final_urls: args.final_urls,
1358
+ sitelink_asset: sitelink
1359
+ }
1360
+ ]),
1361
+ "createSitelink"
1362
+ );
1363
+ const results = result.results || [];
1364
+ const resourceName = results[0]?.resource_name;
1365
+ const assetId = resourceName ? resourceName.split("/").pop() : void 0;
1366
+ if (resourceName) {
1367
+ await this.autoLabelCreated(customerId, [resourceName], "asset");
1368
+ }
1369
+ return {
1370
+ customer_id: cleanId,
1371
+ asset_id: assetId,
1372
+ resource_name: resourceName,
1373
+ link_text: args.link_text,
1374
+ final_urls: args.final_urls,
1375
+ description1: args.description1,
1376
+ description2: args.description2
1377
+ };
1378
+ }
1379
+ async replaceSitelinkUrl(customerId, args) {
1380
+ const customer = this.getCustomer(customerId);
1381
+ const cleanId = customerId.replace(/-/g, "");
1382
+ const oldAssetRows = await withResilience(
1383
+ () => customer.query(`
1384
+ SELECT
1385
+ asset.id,
1386
+ asset.type,
1387
+ asset.final_urls,
1388
+ asset.sitelink_asset.link_text,
1389
+ asset.sitelink_asset.description1,
1390
+ asset.sitelink_asset.description2
1391
+ FROM asset
1392
+ WHERE asset.id = ${args.old_asset_id}
1393
+ `),
1394
+ "replaceSitelinkUrl.queryAsset"
1395
+ );
1396
+ const oldAsset = oldAssetRows[0]?.asset;
1397
+ if (!oldAsset) {
1398
+ throw new Error(`Asset ${args.old_asset_id} not found in customer ${cleanId}`);
1399
+ }
1400
+ if (oldAsset.type !== enums.AssetType.SITELINK && oldAsset.type !== 13) {
1401
+ throw new Error(
1402
+ `Asset ${args.old_asset_id} is type ${oldAsset.type} (not SITELINK). This tool only replaces sitelink assets; use google_ads_update_asset_urls for other asset types.`
1403
+ );
1404
+ }
1405
+ const link_text = args.new_link_text ?? oldAsset.sitelink_asset?.link_text;
1406
+ if (!link_text) {
1407
+ throw new Error(
1408
+ `Old asset has no link_text and none was provided. Pass new_link_text to override.`
1409
+ );
1410
+ }
1411
+ const description1 = args.new_description1 ?? oldAsset.sitelink_asset?.description1 ?? void 0;
1412
+ const description2 = args.new_description2 ?? oldAsset.sitelink_asset?.description2 ?? void 0;
1413
+ const campaignLinksRows = await withResilience(
1414
+ () => customer.query(`
1415
+ SELECT
1416
+ campaign_asset.resource_name,
1417
+ campaign_asset.campaign,
1418
+ campaign_asset.field_type,
1419
+ campaign_asset.status
1420
+ FROM campaign_asset
1421
+ WHERE campaign_asset.asset = 'customers/${cleanId}/assets/${args.old_asset_id}'
1422
+ AND campaign_asset.field_type = 'SITELINK'
1423
+ AND campaign_asset.status = 'ENABLED'
1424
+ `),
1425
+ "replaceSitelinkUrl.queryCampaignAssets"
1426
+ );
1427
+ const adGroupLinksRows = await withResilience(
1428
+ () => customer.query(`
1429
+ SELECT
1430
+ ad_group_asset.resource_name,
1431
+ ad_group_asset.ad_group,
1432
+ ad_group_asset.field_type,
1433
+ ad_group_asset.status
1434
+ FROM ad_group_asset
1435
+ WHERE ad_group_asset.asset = 'customers/${cleanId}/assets/${args.old_asset_id}'
1436
+ AND ad_group_asset.field_type = 'SITELINK'
1437
+ AND ad_group_asset.status = 'ENABLED'
1438
+ `),
1439
+ "replaceSitelinkUrl.queryAdGroupAssets"
1440
+ );
1441
+ const customerLinksRows = await withResilience(
1442
+ () => customer.query(`
1443
+ SELECT
1444
+ customer_asset.resource_name,
1445
+ customer_asset.field_type,
1446
+ customer_asset.status
1447
+ FROM customer_asset
1448
+ WHERE customer_asset.asset = 'customers/${cleanId}/assets/${args.old_asset_id}'
1449
+ AND customer_asset.field_type = 'SITELINK'
1450
+ AND customer_asset.status = 'ENABLED'
1451
+ `),
1452
+ "replaceSitelinkUrl.queryCustomerAssets"
1453
+ );
1454
+ const campaignLinks = campaignLinksRows.map((r) => ({
1455
+ resource_name: r.campaign_asset.resource_name,
1456
+ campaign: r.campaign_asset.campaign
1457
+ }));
1458
+ const adGroupLinks = adGroupLinksRows.map((r) => ({
1459
+ resource_name: r.ad_group_asset.resource_name,
1460
+ ad_group: r.ad_group_asset.ad_group
1461
+ }));
1462
+ const customerLinks = customerLinksRows.map((r) => ({
1463
+ resource_name: r.customer_asset.resource_name
1464
+ }));
1465
+ const newSitelink = { link_text };
1466
+ if (description1) newSitelink.description1 = description1;
1467
+ if (description2) newSitelink.description2 = description2;
1468
+ const createResult = await withResilience(
1469
+ () => customer.assets.create([
1470
+ {
1471
+ type: enums.AssetType.SITELINK,
1472
+ final_urls: args.new_final_urls,
1473
+ sitelink_asset: newSitelink
1474
+ }
1475
+ ]),
1476
+ "replaceSitelinkUrl.createAsset"
1477
+ );
1478
+ const newAssetResource = createResult.results?.[0]?.resource_name;
1479
+ if (!newAssetResource) {
1480
+ throw new Error("Failed to create replacement sitelink asset (no resource_name in response)");
1481
+ }
1482
+ const newAssetId = newAssetResource.split("/").pop() || "";
1483
+ await this.autoLabelCreated(customerId, [newAssetResource], "asset");
1484
+ const SITELINK_FIELD_TYPE = enums.AssetFieldType.SITELINK;
1485
+ if (campaignLinks.length > 0) {
1486
+ await withResilience(
1487
+ () => customer.campaignAssets.create(
1488
+ campaignLinks.map((l) => ({
1489
+ campaign: l.campaign,
1490
+ asset: newAssetResource,
1491
+ field_type: SITELINK_FIELD_TYPE
1492
+ }))
1493
+ ),
1494
+ "replaceSitelinkUrl.createCampaignAssets"
1495
+ );
1496
+ }
1497
+ if (adGroupLinks.length > 0) {
1498
+ await withResilience(
1499
+ () => customer.adGroupAssets.create(
1500
+ adGroupLinks.map((l) => ({
1501
+ ad_group: l.ad_group,
1502
+ asset: newAssetResource,
1503
+ field_type: SITELINK_FIELD_TYPE
1504
+ }))
1505
+ ),
1506
+ "replaceSitelinkUrl.createAdGroupAssets"
1507
+ );
1508
+ }
1509
+ if (customerLinks.length > 0) {
1510
+ await withResilience(
1511
+ () => customer.customerAssets.create(
1512
+ customerLinks.map(() => ({
1513
+ asset: newAssetResource,
1514
+ field_type: SITELINK_FIELD_TYPE
1515
+ }))
1516
+ ),
1517
+ "replaceSitelinkUrl.createCustomerAssets"
1518
+ );
1519
+ }
1520
+ if (campaignLinks.length > 0) {
1521
+ await withResilience(
1522
+ () => customer.campaignAssets.remove(campaignLinks.map((l) => l.resource_name)),
1523
+ "replaceSitelinkUrl.removeCampaignAssets"
1524
+ );
1525
+ }
1526
+ if (adGroupLinks.length > 0) {
1527
+ await withResilience(
1528
+ () => customer.adGroupAssets.remove(adGroupLinks.map((l) => l.resource_name)),
1529
+ "replaceSitelinkUrl.removeAdGroupAssets"
1530
+ );
1531
+ }
1532
+ if (customerLinks.length > 0) {
1533
+ await withResilience(
1534
+ () => customer.customerAssets.remove(customerLinks.map((l) => l.resource_name)),
1535
+ "replaceSitelinkUrl.removeCustomerAssets"
1536
+ );
1537
+ }
1538
+ return {
1539
+ customer_id: cleanId,
1540
+ old_asset_id: args.old_asset_id,
1541
+ new_asset_id: newAssetId,
1542
+ new_asset_resource_name: newAssetResource,
1543
+ link_text,
1544
+ final_urls: args.new_final_urls,
1545
+ description1,
1546
+ description2,
1547
+ relinked: {
1548
+ campaign_assets: campaignLinks.length,
1549
+ ad_group_assets: adGroupLinks.length,
1550
+ customer_assets: customerLinks.length
1551
+ },
1552
+ note: "The old Asset is not deleted; only its ENABLED links were migrated. Paused/removed links on the old asset were left alone."
1553
+ };
1554
+ }
1555
+ // ============================================
1556
+ // AD GROUP RENAME
1557
+ // ============================================
1558
+ async renameAdGroup(customerId, adGroupId, newName) {
1559
+ const customer = this.getCustomer(customerId);
1560
+ const cleanId = customerId.replace(/-/g, "");
1561
+ const resourceName = `customers/${cleanId}/adGroups/${adGroupId}`;
1562
+ await withResilience(
1563
+ () => customer.adGroups.update([{ resource_name: resourceName, name: newName }]),
1564
+ "renameAdGroup"
1565
+ );
1566
+ await this.autoLabelCreated(customerId, [resourceName], "ad_group");
1567
+ return {
1568
+ customer_id: cleanId,
1569
+ ad_group_id: adGroupId,
1570
+ resource_name: resourceName,
1571
+ new_name: newName
1572
+ };
1573
+ }
1574
+ // ============================================
1575
+ // LINK ASSET TO CAMPAIGN
1576
+ // ============================================
1577
+ async linkAssetToCampaign(customerId, args) {
1578
+ const customer = this.getCustomer(customerId);
1579
+ const cleanId = customerId.replace(/-/g, "");
1580
+ const fieldTypeMap = {
1581
+ SITELINK: enums.AssetFieldType.SITELINK,
1582
+ CALLOUT: enums.AssetFieldType.CALLOUT,
1583
+ STRUCTURED_SNIPPET: enums.AssetFieldType.STRUCTURED_SNIPPET
1584
+ };
1585
+ const fieldType = fieldTypeMap[args.field_type.toUpperCase()];
1586
+ if (fieldType === void 0) {
1587
+ throw new Error(
1588
+ `Unknown field_type: "${args.field_type}". Supported values: SITELINK, CALLOUT, STRUCTURED_SNIPPET`
1589
+ );
1590
+ }
1591
+ const assetResource = `customers/${cleanId}/assets/${args.asset_id}`;
1592
+ const operations = args.campaign_ids.map((id) => ({
1593
+ campaign: `customers/${cleanId}/campaigns/${id}`,
1594
+ asset: assetResource,
1595
+ field_type: fieldType
1596
+ }));
1597
+ const result = await withResilience(
1598
+ () => customer.campaignAssets.create(operations),
1599
+ "linkAssetToCampaign"
1600
+ );
1601
+ const results = result.results || [];
1602
+ const resourceNames = results.map((r) => r.resource_name).filter(Boolean);
1603
+ return {
1604
+ customer_id: cleanId,
1605
+ asset_id: args.asset_id,
1606
+ asset_resource_name: assetResource,
1607
+ field_type: args.field_type,
1608
+ campaigns_linked: args.campaign_ids.length,
1609
+ resource_names: resourceNames
1610
+ };
1611
+ }
1612
+ // ============================================
1613
+ // EXPERIMENT METHODS
1614
+ // ============================================
1615
+ async createExperiment(customerId, input) {
1616
+ const customer = this.getCustomer(customerId);
1617
+ const cleanId = customerId.replace(/-/g, "");
1618
+ const trafficSplit = input.traffic_split_percent ?? 50;
1619
+ const experimentPayload = buildExperimentPayload(input);
1620
+ const expResult = await withResilience(
1621
+ () => customer.experiments.create([experimentPayload]),
1622
+ "createExperiment.create"
1623
+ );
1624
+ const expResourceName = expResult.results?.[0]?.resource_name;
1625
+ if (!expResourceName) {
1626
+ throw new Error("Experiment creation returned no resource_name");
1627
+ }
1628
+ const experimentId = experimentIdFromResourceName(expResourceName);
1629
+ const baseCampaignRN = `customers/${cleanId}/campaigns/${input.base_campaign_id}`;
1630
+ const controlArm = buildControlArmPayload(expResourceName, baseCampaignRN, trafficSplit);
1631
+ const treatmentArm = buildTreatmentArmPayload(expResourceName, trafficSplit);
1632
+ const armsResult = await withResilience(
1633
+ () => customer.experimentArms.create([controlArm, treatmentArm]),
1634
+ "createExperiment.arms"
1635
+ );
1636
+ const treatmentArmRN = armsResult.results?.[1]?.resource_name;
1637
+ let treatmentCampaignId = null;
1638
+ if (treatmentArmRN) {
1639
+ const armRows = await withResilience(
1640
+ () => customer.query(`
1641
+ SELECT experiment_arm.resource_name, experiment_arm.in_design_campaigns
1642
+ FROM experiment_arm
1643
+ WHERE experiment_arm.resource_name = '${treatmentArmRN}'
1644
+ `),
1645
+ "createExperiment.queryTreatmentArm"
1646
+ );
1647
+ const inDesign = armRows[0]?.experiment_arm?.in_design_campaigns ?? [];
1648
+ if (inDesign.length > 0) {
1649
+ treatmentCampaignId = campaignIdFromResourceName(inDesign[0]);
1650
+ }
1651
+ }
1652
+ await this.autoLabelCreated(customerId, [expResourceName], "experiment");
1653
+ return {
1654
+ customer_id: cleanId,
1655
+ experiment_id: experimentId,
1656
+ experiment_resource_name: expResourceName,
1657
+ treatment_campaign_id: treatmentCampaignId,
1658
+ name: input.name,
1659
+ traffic_split_percent: trafficSplit,
1660
+ status: "SETUP",
1661
+ note: treatmentCampaignId ? `Treatment campaign ${treatmentCampaignId} created. Use google_ads_update_campaign_ad_urls to change landing pages, then google_ads_schedule_experiment to go live.` : "Treatment campaign not yet available \u2014 query google_ads_get_experiment in a few seconds."
1662
+ };
1663
+ }
1664
+ async listExperiments(customerId, options = {}) {
1665
+ const customer = this.getCustomer(customerId);
1666
+ const statusClause = options.statusFilter && options.statusFilter !== "ALL" ? `AND experiment.status = '${options.statusFilter}'` : `AND experiment.status != 'REMOVED'`;
1667
+ const rows = await withResilience(
1668
+ () => customer.query(`
1669
+ SELECT
1670
+ experiment.resource_name,
1671
+ experiment.experiment_id,
1672
+ experiment.name,
1673
+ experiment.description,
1674
+ experiment.status,
1675
+ experiment.type,
1676
+ experiment.start_date,
1677
+ experiment.end_date,
1678
+ experiment.suffix
1679
+ FROM experiment
1680
+ WHERE experiment.type = 'SEARCH_CUSTOM'
1681
+ ${statusClause}
1682
+ ORDER BY experiment.name
1683
+ `),
1684
+ "listExperiments"
1685
+ );
1686
+ let experiments = rows.map((r) => ({
1687
+ experiment_id: String(r.experiment?.experiment_id ?? ""),
1688
+ resource_name: r.experiment?.resource_name ?? "",
1689
+ name: r.experiment?.name ?? "",
1690
+ description: r.experiment?.description ?? "",
1691
+ status: r.experiment?.status ?? "",
1692
+ start_date: r.experiment?.start_date ?? "",
1693
+ end_date: r.experiment?.end_date ?? "",
1694
+ suffix: r.experiment?.suffix ?? ""
1695
+ }));
1696
+ if (options.campaignId) {
1697
+ const armRows = await withResilience(
1698
+ () => customer.query(`
1699
+ SELECT experiment_arm.experiment, experiment_arm.campaigns, experiment_arm.control
1700
+ FROM experiment_arm
1701
+ WHERE experiment_arm.control = TRUE
1702
+ `),
1703
+ "listExperiments.arms"
1704
+ );
1705
+ const cleanCid = customerId.replace(/-/g, "");
1706
+ const targetRN = `customers/${cleanCid}/campaigns/${options.campaignId}`;
1707
+ const expRNsForCampaign = new Set(
1708
+ armRows.filter((r) => (r.experiment_arm?.campaigns ?? []).includes(targetRN)).map((r) => r.experiment_arm?.experiment)
1709
+ );
1710
+ experiments = experiments.filter((e) => expRNsForCampaign.has(e.resource_name));
1711
+ }
1712
+ return { customer_id: customerId.replace(/-/g, ""), experiments };
1713
+ }
1714
+ async getExperiment(customerId, experimentId) {
1715
+ const customer = this.getCustomer(customerId);
1716
+ const cleanId = customerId.replace(/-/g, "");
1717
+ const expRN = `customers/${cleanId}/experiments/${experimentId}`;
1718
+ const [expRows, armRows] = await Promise.all([
1719
+ withResilience(
1720
+ () => customer.query(`
1721
+ SELECT
1722
+ experiment.resource_name,
1723
+ experiment.experiment_id,
1724
+ experiment.name,
1725
+ experiment.description,
1726
+ experiment.status,
1727
+ experiment.type,
1728
+ experiment.start_date,
1729
+ experiment.end_date,
1730
+ experiment.suffix
1731
+ FROM experiment
1732
+ WHERE experiment.resource_name = '${expRN}'
1733
+ `),
1734
+ "getExperiment.exp"
1735
+ ),
1736
+ withResilience(
1737
+ () => customer.query(`
1738
+ SELECT
1739
+ experiment_arm.resource_name,
1740
+ experiment_arm.name,
1741
+ experiment_arm.control,
1742
+ experiment_arm.traffic_split,
1743
+ experiment_arm.campaigns,
1744
+ experiment_arm.in_design_campaigns
1745
+ FROM experiment_arm
1746
+ WHERE experiment_arm.experiment = '${expRN}'
1747
+ `),
1748
+ "getExperiment.arms"
1749
+ )
1750
+ ]);
1751
+ if (expRows.length === 0) {
1752
+ throw new Error(`Experiment ${experimentId} not found`);
1753
+ }
1754
+ const exp = expRows[0].experiment;
1755
+ const arms = armRows.map((r) => {
1756
+ const arm = r.experiment_arm;
1757
+ const isControl = arm?.control ?? false;
1758
+ const campaigns = arm?.campaigns ?? [];
1759
+ const inDesignCampaigns = arm?.in_design_campaigns ?? [];
1760
+ return {
1761
+ name: arm?.name ?? "",
1762
+ control: isControl,
1763
+ traffic_split: arm?.traffic_split ?? 0,
1764
+ campaign_ids: campaigns.map(campaignIdFromResourceName),
1765
+ treatment_campaign_ids: inDesignCampaigns.map(campaignIdFromResourceName)
1766
+ };
1767
+ });
1768
+ const treatmentArm = arms.find((a) => !a.control);
1769
+ const treatmentCampaignId = treatmentArm?.treatment_campaign_ids?.[0] ?? null;
1770
+ return {
1771
+ experiment_id: String(exp?.experiment_id ?? experimentId),
1772
+ resource_name: exp?.resource_name ?? expRN,
1773
+ name: exp?.name ?? "",
1774
+ description: exp?.description ?? "",
1775
+ status: exp?.status ?? "",
1776
+ start_date: exp?.start_date ?? "",
1777
+ end_date: exp?.end_date ?? "",
1778
+ suffix: exp?.suffix ?? "",
1779
+ arms,
1780
+ treatment_campaign_id: treatmentCampaignId
1781
+ };
1782
+ }
1783
+ async scheduleExperiment(customerId, experimentId) {
1784
+ const customer = this.getCustomer(customerId);
1785
+ const cleanId = customerId.replace(/-/g, "");
1786
+ const expRN = `customers/${cleanId}/experiments/${experimentId}`;
1787
+ await withResilience(
1788
+ () => customer.experiments.scheduleExperiment({ resource_name: expRN }),
1789
+ "scheduleExperiment"
1790
+ );
1791
+ return { customer_id: cleanId, experiment_id: experimentId, status: "SCHEDULED" };
1792
+ }
1793
+ async endExperiment(customerId, experimentId) {
1794
+ const customer = this.getCustomer(customerId);
1795
+ const cleanId = customerId.replace(/-/g, "");
1796
+ const expRN = `customers/${cleanId}/experiments/${experimentId}`;
1797
+ await withResilience(
1798
+ () => customer.experiments.endExperiment({ resource_name: expRN }),
1799
+ "endExperiment"
1800
+ );
1801
+ return { customer_id: cleanId, experiment_id: experimentId, status: "HALTED" };
1802
+ }
1803
+ async promoteExperiment(customerId, experimentId, validateOnly = false) {
1804
+ const customer = this.getCustomer(customerId);
1805
+ const cleanId = customerId.replace(/-/g, "");
1806
+ const expRN = `customers/${cleanId}/experiments/${experimentId}`;
1807
+ await withResilience(
1808
+ () => customer.experiments.promoteExperiment({ resource_name: expRN, validate_only: validateOnly }),
1809
+ "promoteExperiment"
1810
+ );
1811
+ return {
1812
+ customer_id: cleanId,
1813
+ experiment_id: experimentId,
1814
+ status: validateOnly ? "VALIDATED (not promoted)" : "PROMOTED"
1815
+ };
1816
+ }
1817
+ async removeExperiment(customerId, experimentId) {
1818
+ const customer = this.getCustomer(customerId);
1819
+ const cleanId = customerId.replace(/-/g, "");
1820
+ const expRN = `customers/${cleanId}/experiments/${experimentId}`;
1821
+ await withResilience(
1822
+ () => customer.experiments.remove([expRN]),
1823
+ "removeExperiment"
1824
+ );
1825
+ return {
1826
+ customer_id: cleanId,
1827
+ experiment_id: experimentId,
1828
+ status: "REMOVED"
1829
+ };
1830
+ }
1831
+ async updateCampaignAdUrls(customerId, campaignId, newFinalUrl, execute = false) {
1832
+ const customer = this.getCustomer(customerId);
1833
+ const cleanId = customerId.replace(/-/g, "");
1834
+ const rows = await withResilience(
1835
+ () => customer.query(`
1836
+ SELECT
1837
+ ad_group_ad.resource_name,
1838
+ ad_group_ad.ad.id,
1839
+ ad_group_ad.ad.final_urls,
1840
+ ad_group_ad.status,
1841
+ ad_group.name,
1842
+ campaign.name
1843
+ FROM ad_group_ad
1844
+ WHERE campaign.id = ${sanitizeNumericId(campaignId)}
1845
+ AND ad_group_ad.status != 'REMOVED'
1846
+ ORDER BY ad_group.name
1847
+ `),
1848
+ "updateCampaignAdUrls.query"
1849
+ );
1850
+ const ads = rows.map((r) => ({
1851
+ resource_name: r.ad_group_ad?.resource_name,
1852
+ ad_id: String(r.ad_group_ad?.ad?.id ?? ""),
1853
+ current_final_urls: r.ad_group_ad?.ad?.final_urls ?? [],
1854
+ ad_group_name: r.ad_group?.name ?? "",
1855
+ status: r.ad_group_ad?.status ?? ""
1856
+ }));
1857
+ const preview = ads.map((a) => ({
1858
+ ad_id: a.ad_id,
1859
+ ad_group: a.ad_group_name,
1860
+ from: a.current_final_urls,
1861
+ to: [newFinalUrl]
1862
+ }));
1863
+ if (!execute) {
1864
+ return {
1865
+ customer_id: cleanId,
1866
+ campaign_id: campaignId,
1867
+ dry_run: true,
1868
+ ads_to_update: ads.length,
1869
+ preview,
1870
+ note: "Set execute=true to apply these changes."
1871
+ };
1872
+ }
1873
+ const updates = ads.map((a) => ({
1874
+ resource_name: a.resource_name,
1875
+ ad: { final_urls: [newFinalUrl] }
1876
+ }));
1877
+ if (updates.length > 0) {
1878
+ await withResilience(
1879
+ () => customer.adGroupAds.update(updates),
1880
+ "updateCampaignAdUrls.update"
1881
+ );
1882
+ }
1883
+ await this.autoLabelCreated(customerId, ads.map((a) => a.resource_name).filter(Boolean), "ad");
1884
+ return {
1885
+ customer_id: cleanId,
1886
+ campaign_id: campaignId,
1887
+ dry_run: false,
1888
+ ads_updated: updates.length,
1889
+ new_final_url: newFinalUrl,
1890
+ preview
1891
+ };
1892
+ }
1893
+ // ============================================
1243
1894
  // REPORTING METHODS
1244
1895
  // ============================================
1245
1896
  // Get keyword performance report
@@ -2131,6 +2782,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2131
2782
  }]
2132
2783
  };
2133
2784
  }
2785
+ case "google_ads_set_ad_group_location_targeting": {
2786
+ const customerId = args?.customer_id || "";
2787
+ const result = await adsManager.setAdGroupLocationTargeting(
2788
+ customerId,
2789
+ args?.ad_group_id,
2790
+ args?.geo_target_ids
2791
+ );
2792
+ return {
2793
+ content: [{
2794
+ type: "text",
2795
+ text: JSON.stringify({
2796
+ success: true,
2797
+ message: `Location targeting set: ${(args?.geo_target_ids).length} geo targets added to ad group ${args?.ad_group_id}`,
2798
+ results: result
2799
+ }, null, 2)
2800
+ }]
2801
+ };
2802
+ }
2803
+ case "google_ads_set_campaign_location_targeting": {
2804
+ const customerId = args?.customer_id || "";
2805
+ const result = await adsManager.setCampaignLocationTargeting(
2806
+ customerId,
2807
+ args?.campaign_id,
2808
+ args?.geo_target_ids
2809
+ );
2810
+ return {
2811
+ content: [{
2812
+ type: "text",
2813
+ text: JSON.stringify({
2814
+ success: true,
2815
+ message: `Location targeting set: ${(args?.geo_target_ids).length} geo targets added to campaign ${args?.campaign_id}`,
2816
+ results: result
2817
+ }, null, 2)
2818
+ }]
2819
+ };
2820
+ }
2134
2821
  case "google_ads_add_campaign_negatives": {
2135
2822
  const customerId = args?.customer_id || "";
2136
2823
  const result = await adsManager.addCampaignNegativeKeywords(
@@ -2202,6 +2889,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2202
2889
  }]
2203
2890
  };
2204
2891
  }
2892
+ case "google_ads_enable_keywords": {
2893
+ const customerId = args?.customer_id || "";
2894
+ const result = await adsManager.enableKeywords(
2895
+ customerId,
2896
+ args?.criterion_resource_names,
2897
+ args?.labels
2898
+ );
2899
+ return {
2900
+ content: [{
2901
+ type: "text",
2902
+ text: JSON.stringify({
2903
+ success: true,
2904
+ message: "Keywords enabled and now LIVE",
2905
+ results: result
2906
+ }, null, 2)
2907
+ }]
2908
+ };
2909
+ }
2205
2910
  // ============================================
2206
2911
  // REPORTING HANDLERS
2207
2912
  // ============================================
@@ -2444,6 +3149,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2444
3149
  const result = await adsManager.updateAssetUrls(customerId, normalized.updates);
2445
3150
  return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
2446
3151
  }
3152
+ case "google_ads_create_sitelink": {
3153
+ const normalized = normalizeCreateSitelinkArgs(args);
3154
+ if ("error" in normalized) {
3155
+ return { content: [{ type: "text", text: JSON.stringify({ error: normalized.error }, null, 2) }] };
3156
+ }
3157
+ if (!normalized.confirm) {
3158
+ return { content: [{ type: "text", text: JSON.stringify(buildCreateSitelinkDryRun(normalized), null, 2) }] };
3159
+ }
3160
+ const customerId = normalized.customer_id || "";
3161
+ const result = await adsManager.createSitelink(customerId, {
3162
+ link_text: normalized.link_text,
3163
+ final_urls: normalized.final_urls,
3164
+ description1: normalized.description1,
3165
+ description2: normalized.description2
3166
+ });
3167
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3168
+ }
3169
+ case "google_ads_replace_sitelink_url": {
3170
+ const normalized = normalizeReplaceSitelinkArgs(args);
3171
+ if ("error" in normalized) {
3172
+ return { content: [{ type: "text", text: JSON.stringify({ error: normalized.error }, null, 2) }] };
3173
+ }
3174
+ if (!normalized.confirm) {
3175
+ return { content: [{ type: "text", text: JSON.stringify(buildReplaceSitelinkDryRun(normalized), null, 2) }] };
3176
+ }
3177
+ const customerId = normalized.customer_id || "";
3178
+ const result = await adsManager.replaceSitelinkUrl(customerId, {
3179
+ old_asset_id: normalized.old_asset_id,
3180
+ new_final_urls: normalized.new_final_urls,
3181
+ new_link_text: normalized.new_link_text,
3182
+ new_description1: normalized.new_description1,
3183
+ new_description2: normalized.new_description2
3184
+ });
3185
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3186
+ }
2447
3187
  case "google_ads_pause_asset_links": {
2448
3188
  const normalized = normalizePauseAssetLinksArgs(args);
2449
3189
  if ("error" in normalized) {
@@ -2456,6 +3196,61 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2456
3196
  const result = await adsManager.pauseAssetLinks(customerId, normalized.resource_names);
2457
3197
  return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
2458
3198
  }
3199
+ case "google_ads_rename_ad_group": {
3200
+ const customerId = args?.customer_id || "";
3201
+ const adGroupId = args?.ad_group_id;
3202
+ const newName = args?.new_name;
3203
+ if (!adGroupId || !newName) {
3204
+ return { content: [{ type: "text", text: JSON.stringify({ error: "ad_group_id and new_name are required." }, null, 2) }] };
3205
+ }
3206
+ if (!args?.confirm) {
3207
+ return {
3208
+ content: [{
3209
+ type: "text",
3210
+ text: JSON.stringify({
3211
+ dry_run: true,
3212
+ action: "rename_ad_group",
3213
+ ad_group_id: adGroupId,
3214
+ new_name: newName,
3215
+ message: "Pass confirm: true to apply the rename."
3216
+ }, null, 2)
3217
+ }]
3218
+ };
3219
+ }
3220
+ const renameResult = await adsManager.renameAdGroup(customerId, adGroupId, newName);
3221
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...renameResult }, null, 2) }] };
3222
+ }
3223
+ case "google_ads_link_asset_to_campaign": {
3224
+ const customerId = args?.customer_id || "";
3225
+ const assetId = args?.asset_id;
3226
+ const campaignIds = args?.campaign_ids;
3227
+ const fieldType = args?.field_type;
3228
+ if (!assetId || !campaignIds?.length || !fieldType) {
3229
+ return { content: [{ type: "text", text: JSON.stringify({ error: "asset_id, campaign_ids (non-empty), and field_type are required." }, null, 2) }] };
3230
+ }
3231
+ if (!args?.confirm) {
3232
+ return {
3233
+ content: [{
3234
+ type: "text",
3235
+ text: JSON.stringify({
3236
+ dry_run: true,
3237
+ action: "link_asset_to_campaign",
3238
+ asset_id: assetId,
3239
+ campaign_ids: campaignIds,
3240
+ field_type: fieldType,
3241
+ campaigns_to_link: campaignIds.length,
3242
+ message: "Pass confirm: true to apply the links."
3243
+ }, null, 2)
3244
+ }]
3245
+ };
3246
+ }
3247
+ const linkResult = await adsManager.linkAssetToCampaign(customerId, {
3248
+ asset_id: assetId,
3249
+ campaign_ids: campaignIds,
3250
+ field_type: fieldType
3251
+ });
3252
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...linkResult }, null, 2) }] };
3253
+ }
2459
3254
  case "google_ads_gaql_query": {
2460
3255
  const customerId = args?.customer_id || "";
2461
3256
  const query = args?.query;
@@ -2533,6 +3328,70 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2533
3328
  };
2534
3329
  }
2535
3330
  }
3331
+ case "google_ads_create_page_feed": {
3332
+ const customerId = args?.customer_id || "";
3333
+ const campaignId = args?.campaign_id;
3334
+ const feedName = args?.name;
3335
+ const urls = args?.urls;
3336
+ const label = args?.label ?? "page-feed";
3337
+ try {
3338
+ const customer = adsManager.getCustomer(customerId);
3339
+ const assetCreateResult = await withResilience(
3340
+ () => customer.assets.create(
3341
+ urls.map((url) => ({
3342
+ type: enums.AssetType.PAGE_FEED,
3343
+ page_feed_asset: { page_url: url, labels: [label] }
3344
+ }))
3345
+ ),
3346
+ "createPageFeedAssets"
3347
+ );
3348
+ const assetResources = (assetCreateResult.results || []).map((r) => r.resource_name);
3349
+ const assetSetCreateResult = await withResilience(
3350
+ () => customer.asset_sets.create([{
3351
+ name: feedName,
3352
+ type: enums.AssetSetType.PAGE_FEED
3353
+ }]),
3354
+ "createAssetSet"
3355
+ );
3356
+ const assetSetResource = (assetSetCreateResult.results || [])[0]?.resource_name;
3357
+ await withResilience(
3358
+ () => customer.asset_set_assets.create(
3359
+ assetResources.map((assetResource) => ({
3360
+ asset_set: assetSetResource,
3361
+ asset: assetResource
3362
+ }))
3363
+ ),
3364
+ "linkAssetsToSet"
3365
+ );
3366
+ const casCreateResult = await withResilience(
3367
+ () => customer.campaign_asset_sets.create([{
3368
+ campaign: `customers/${customerId}/campaigns/${campaignId}`,
3369
+ asset_set: assetSetResource
3370
+ }]),
3371
+ "attachAssetSetToCampaign"
3372
+ );
3373
+ const casResource = (casCreateResult.results || [])[0]?.resource_name;
3374
+ return {
3375
+ content: [{
3376
+ type: "text",
3377
+ text: JSON.stringify({
3378
+ success: true,
3379
+ asset_set_resource: assetSetResource,
3380
+ campaign_asset_set_resource: casResource,
3381
+ urls_added: urls.length,
3382
+ label
3383
+ }, null, 2)
3384
+ }]
3385
+ };
3386
+ } catch (e) {
3387
+ return {
3388
+ content: [{
3389
+ type: "text",
3390
+ text: JSON.stringify({ success: false, error: e.message }, null, 2)
3391
+ }]
3392
+ };
3393
+ }
3394
+ }
2536
3395
  case "google_ads_create_image_asset": {
2537
3396
  const customerId = args?.customer_id || "";
2538
3397
  try {
@@ -2556,6 +3415,101 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2556
3415
  };
2557
3416
  }
2558
3417
  }
3418
+ // ============================================
3419
+ // EXPERIMENT DISPATCH
3420
+ // ============================================
3421
+ case "google_ads_create_experiment": {
3422
+ assertWriteAllowed(name);
3423
+ const customerId = args?.customer_id || "";
3424
+ try {
3425
+ const result = await adsManager.createExperiment(customerId, {
3426
+ base_campaign_id: args?.base_campaign_id,
3427
+ name: args?.name,
3428
+ description: args?.description,
3429
+ suffix: args?.suffix,
3430
+ traffic_split_percent: args?.traffic_split_percent,
3431
+ start_date: args?.start_date,
3432
+ end_date: args?.end_date
3433
+ });
3434
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3435
+ } catch (e) {
3436
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
3437
+ }
3438
+ }
3439
+ case "google_ads_list_experiments": {
3440
+ const customerId = args?.customer_id || "";
3441
+ const result = await adsManager.listExperiments(customerId, {
3442
+ campaignId: args?.campaign_id,
3443
+ statusFilter: args?.status_filter
3444
+ });
3445
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3446
+ }
3447
+ case "google_ads_get_experiment": {
3448
+ const customerId = args?.customer_id || "";
3449
+ try {
3450
+ const result = await adsManager.getExperiment(customerId, args?.experiment_id);
3451
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3452
+ } catch (e) {
3453
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
3454
+ }
3455
+ }
3456
+ case "google_ads_schedule_experiment": {
3457
+ assertWriteAllowed(name);
3458
+ const customerId = args?.customer_id || "";
3459
+ try {
3460
+ const result = await adsManager.scheduleExperiment(customerId, args?.experiment_id);
3461
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3462
+ } catch (e) {
3463
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
3464
+ }
3465
+ }
3466
+ case "google_ads_end_experiment": {
3467
+ assertWriteAllowed(name);
3468
+ const customerId = args?.customer_id || "";
3469
+ try {
3470
+ const result = await adsManager.endExperiment(customerId, args?.experiment_id);
3471
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3472
+ } catch (e) {
3473
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
3474
+ }
3475
+ }
3476
+ case "google_ads_promote_experiment": {
3477
+ assertWriteAllowed(name);
3478
+ const customerId = args?.customer_id || "";
3479
+ try {
3480
+ const validateOnly = args?.validate_only === true;
3481
+ const result = await adsManager.promoteExperiment(customerId, args?.experiment_id, validateOnly);
3482
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3483
+ } catch (e) {
3484
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
3485
+ }
3486
+ }
3487
+ case "google_ads_remove_experiment": {
3488
+ assertWriteAllowed(name);
3489
+ const customerId = args?.customer_id || "";
3490
+ try {
3491
+ const result = await adsManager.removeExperiment(customerId, args?.experiment_id);
3492
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3493
+ } catch (e) {
3494
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
3495
+ }
3496
+ }
3497
+ case "google_ads_update_campaign_ad_urls": {
3498
+ const customerId = args?.customer_id || "";
3499
+ const execute = args?.execute === true;
3500
+ if (execute) assertWriteAllowed(name);
3501
+ try {
3502
+ const result = await adsManager.updateCampaignAdUrls(
3503
+ customerId,
3504
+ args?.campaign_id,
3505
+ args?.new_final_url,
3506
+ execute
3507
+ );
3508
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
3509
+ } catch (e) {
3510
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
3511
+ }
3512
+ }
2559
3513
  default:
2560
3514
  throw new Error(`Unknown tool: ${name}`);
2561
3515
  }