-
Notifications
You must be signed in to change notification settings - Fork 159
Expand file tree
/
Copy pathgoogle_ads_server.py
More file actions
1476 lines (1209 loc) · 53.8 KB
/
Copy pathgoogle_ads_server.py
File metadata and controls
1476 lines (1209 loc) · 53.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from typing import Any, Dict, List, Optional, Union
from pydantic import Field
import os
import json
import requests
from datetime import datetime, timedelta
from pathlib import Path
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from google.oauth2 import service_account
from google.auth.transport.requests import Request
from google.auth.exceptions import RefreshError
import logging
# MCP
from mcp.server.fastmcp import FastMCP
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('google_ads_server')
mcp = FastMCP(
"google-ads-server",
dependencies=[
"google-auth-oauthlib",
"google-auth",
"requests",
"python-dotenv"
]
)
# Constants and configuration
SCOPES = ['https://www.googleapis.com/auth/adwords']
API_VERSION = "v19" # Google Ads API version
# Load environment variables
try:
from dotenv import load_dotenv
# Load from .env file if it exists
load_dotenv()
logger.info("Environment variables loaded from .env file")
except ImportError:
logger.warning("python-dotenv not installed, skipping .env file loading")
# Get credentials from environment variables
GOOGLE_ADS_CREDENTIALS_PATH = os.environ.get("GOOGLE_ADS_CREDENTIALS_PATH")
GOOGLE_ADS_DEVELOPER_TOKEN = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN")
GOOGLE_ADS_LOGIN_CUSTOMER_ID = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "")
GOOGLE_ADS_AUTH_TYPE = os.environ.get("GOOGLE_ADS_AUTH_TYPE", "oauth") # oauth or service_account
def format_customer_id(customer_id: str) -> str:
"""Format customer ID to ensure it's 10 digits without dashes."""
# Convert to string if passed as integer or another type
customer_id = str(customer_id)
# Remove any quotes surrounding the customer_id (both escaped and unescaped)
customer_id = customer_id.replace('\"', '').replace('"', '')
# Remove any non-digit characters (including dashes, braces, etc.)
customer_id = ''.join(char for char in customer_id if char.isdigit())
# Ensure it's 10 digits with leading zeros if needed
return customer_id.zfill(10)
def get_credentials():
"""
Get and refresh OAuth credentials or service account credentials based on the auth type.
This function supports two authentication methods:
1. OAuth 2.0 (User Authentication) - For individual users or desktop applications
2. Service Account (Server-to-Server Authentication) - For automated systems
Returns:
Valid credentials object to use with Google Ads API
"""
if not GOOGLE_ADS_CREDENTIALS_PATH:
raise ValueError("GOOGLE_ADS_CREDENTIALS_PATH environment variable not set")
auth_type = GOOGLE_ADS_AUTH_TYPE.lower()
logger.info(f"Using authentication type: {auth_type}")
# Service Account authentication
if auth_type == "service_account":
try:
return get_service_account_credentials()
except Exception as e:
logger.error(f"Error with service account authentication: {str(e)}")
raise
# OAuth 2.0 authentication (default)
return get_oauth_credentials()
def get_service_account_credentials():
"""Get credentials using a service account key file."""
logger.info(f"Loading service account credentials from {GOOGLE_ADS_CREDENTIALS_PATH}")
if not os.path.exists(GOOGLE_ADS_CREDENTIALS_PATH):
raise FileNotFoundError(f"Service account key file not found at {GOOGLE_ADS_CREDENTIALS_PATH}")
try:
credentials = service_account.Credentials.from_service_account_file(
GOOGLE_ADS_CREDENTIALS_PATH,
scopes=SCOPES
)
# Check if impersonation is required
impersonation_email = os.environ.get("GOOGLE_ADS_IMPERSONATION_EMAIL")
if impersonation_email:
logger.info(f"Impersonating user: {impersonation_email}")
credentials = credentials.with_subject(impersonation_email)
return credentials
except Exception as e:
logger.error(f"Error loading service account credentials: {str(e)}")
raise
def get_oauth_credentials():
"""Get and refresh OAuth user credentials."""
creds = None
client_config = None
# Path to store the refreshed token
token_path = GOOGLE_ADS_CREDENTIALS_PATH
if os.path.exists(token_path) and not os.path.basename(token_path).endswith('.json'):
# If it's not explicitly a .json file, append a default name
token_dir = os.path.dirname(token_path)
token_path = os.path.join(token_dir, 'google_ads_token.json')
# Check if token file exists and load credentials
if os.path.exists(token_path):
try:
logger.info(f"Loading OAuth credentials from {token_path}")
with open(token_path, 'r') as f:
creds_data = json.load(f)
# Check if this is a client config or saved credentials
if "installed" in creds_data or "web" in creds_data:
client_config = creds_data
logger.info("Found OAuth client configuration")
else:
logger.info("Found existing OAuth token")
creds = Credentials.from_authorized_user_info(creds_data, SCOPES)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON in token file: {token_path}")
creds = None
except Exception as e:
logger.warning(f"Error loading credentials: {str(e)}")
creds = None
# If credentials don't exist or are invalid, get new ones
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
try:
logger.info("Refreshing expired token")
creds.refresh(Request())
logger.info("Token successfully refreshed")
except RefreshError as e:
logger.warning(f"Error refreshing token: {str(e)}, will try to get new token")
creds = None
except Exception as e:
logger.error(f"Unexpected error refreshing token: {str(e)}")
raise
# If we need new credentials
if not creds:
# If no client_config is defined yet, create one from environment variables
if not client_config:
logger.info("Creating OAuth client config from environment variables")
client_id = os.environ.get("GOOGLE_ADS_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_ADS_CLIENT_SECRET")
if not client_id or not client_secret:
raise ValueError("GOOGLE_ADS_CLIENT_ID and GOOGLE_ADS_CLIENT_SECRET must be set if no client config file exists")
client_config = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"]
}
}
# Run the OAuth flow
logger.info("Starting OAuth authentication flow")
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
creds = flow.run_local_server(port=0)
logger.info("OAuth flow completed successfully")
# Save the refreshed/new credentials
try:
logger.info(f"Saving credentials to {token_path}")
# Ensure directory exists
os.makedirs(os.path.dirname(token_path), exist_ok=True)
with open(token_path, 'w') as f:
f.write(creds.to_json())
except Exception as e:
logger.warning(f"Could not save credentials: {str(e)}")
return creds
def get_headers(creds):
"""Get headers for Google Ads API requests."""
if not GOOGLE_ADS_DEVELOPER_TOKEN:
raise ValueError("GOOGLE_ADS_DEVELOPER_TOKEN environment variable not set")
# Handle different credential types
if isinstance(creds, service_account.Credentials):
# For service account, we need to get a new bearer token
auth_req = Request()
creds.refresh(auth_req)
token = creds.token
else:
# For OAuth credentials, check if token needs refresh
if not creds.valid:
if creds.expired and creds.refresh_token:
try:
logger.info("Refreshing expired OAuth token in get_headers")
creds.refresh(Request())
logger.info("Token successfully refreshed in get_headers")
except RefreshError as e:
logger.error(f"Error refreshing token in get_headers: {str(e)}")
raise ValueError(f"Failed to refresh OAuth token: {str(e)}")
except Exception as e:
logger.error(f"Unexpected error refreshing token in get_headers: {str(e)}")
raise
else:
raise ValueError("OAuth credentials are invalid and cannot be refreshed")
token = creds.token
headers = {
'Authorization': f'Bearer {token}',
'developer-token': GOOGLE_ADS_DEVELOPER_TOKEN,
'content-type': 'application/json'
}
if GOOGLE_ADS_LOGIN_CUSTOMER_ID:
headers['login-customer-id'] = format_customer_id(GOOGLE_ADS_LOGIN_CUSTOMER_ID)
return headers
@mcp.tool()
async def list_accounts() -> str:
"""
Lists all accessible Google Ads accounts.
This is typically the first command you should run to identify which accounts
you have access to. The returned account IDs can be used in subsequent commands.
Returns:
A formatted list of all Google Ads accounts accessible with your credentials
"""
try:
creds = get_credentials()
headers = get_headers(creds)
url = f"https://googleads.googleapis.com/{API_VERSION}/customers:listAccessibleCustomers"
response = requests.get(url, headers=headers)
if response.status_code != 200:
return f"Error accessing accounts: {response.text}"
customers = response.json()
if not customers.get('resourceNames'):
return "No accessible accounts found."
# Format the results
result_lines = ["Accessible Google Ads Accounts:"]
result_lines.append("-" * 50)
for resource_name in customers['resourceNames']:
customer_id = resource_name.split('/')[-1]
formatted_id = format_customer_id(customer_id)
result_lines.append(f"Account ID: {formatted_id}")
return "\n".join(result_lines)
except Exception as e:
return f"Error listing accounts: {str(e)}"
@mcp.tool()
async def execute_gaql_query(
customer_id: str = Field(description="Google Ads customer ID (10 digits, no dashes). Example: '9873186703'"),
query: str = Field(description="Valid GAQL query string following Google Ads Query Language syntax")
) -> str:
"""
Execute a custom GAQL (Google Ads Query Language) query.
This tool allows you to run any valid GAQL query against the Google Ads API.
Args:
customer_id: The Google Ads customer ID as a string (10 digits, no dashes)
query: The GAQL query to execute (must follow GAQL syntax)
Returns:
Formatted query results or error message
Example:
customer_id: "1234567890"
query: "SELECT campaign.id, campaign.name FROM campaign LIMIT 10"
"""
try:
creds = get_credentials()
headers = get_headers(creds)
formatted_customer_id = format_customer_id(customer_id)
url = f"https://googleads.googleapis.com/{API_VERSION}/customers/{formatted_customer_id}/googleAds:search"
payload = {"query": query}
response = requests.post(url, headers=headers, json=payload)
if response.status_code != 200:
return f"Error executing query: {response.text}"
results = response.json()
if not results.get('results'):
return "No results found for the query."
# Format the results as a table
result_lines = [f"Query Results for Account {formatted_customer_id}:"]
result_lines.append("-" * 80)
# Get field names from the first result
fields = []
first_result = results['results'][0]
for key in first_result:
if isinstance(first_result[key], dict):
for subkey in first_result[key]:
fields.append(f"{key}.{subkey}")
else:
fields.append(key)
# Add header
result_lines.append(" | ".join(fields))
result_lines.append("-" * 80)
# Add data rows
for result in results['results']:
row_data = []
for field in fields:
if "." in field:
parent, child = field.split(".")
value = str(result.get(parent, {}).get(child, ""))
else:
value = str(result.get(field, ""))
row_data.append(value)
result_lines.append(" | ".join(row_data))
return "\n".join(result_lines)
except Exception as e:
return f"Error executing GAQL query: {str(e)}"
@mcp.tool()
async def get_campaign_performance(
customer_id: str = Field(description="Google Ads customer ID (10 digits, no dashes). Example: '9873186703'"),
days: int = Field(default=30, description="Number of days to look back (7, 30, 90, etc.)")
) -> str:
"""
Get campaign performance metrics for the specified time period.
RECOMMENDED WORKFLOW:
1. First run list_accounts() to get available account IDs
2. Then run get_account_currency() to see what currency the account uses
3. Finally run this command to get campaign performance
Args:
customer_id: The Google Ads customer ID as a string (10 digits, no dashes)
days: Number of days to look back (default: 30)
Returns:
Formatted table of campaign performance data
Note:
Cost values are in micros (millionths) of the account currency
(e.g., 1000000 = 1 USD in a USD account)
Example:
customer_id: "1234567890"
days: 14
"""
query = f"""
SELECT
campaign.id,
campaign.name,
campaign.status,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.average_cpc
FROM campaign
WHERE segments.date DURING LAST_{days}_DAYS
ORDER BY metrics.cost_micros DESC
LIMIT 50
"""
return await execute_gaql_query(customer_id, query)
@mcp.tool()
async def get_ad_performance(
customer_id: str = Field(description="Google Ads customer ID (10 digits, no dashes). Example: '9873186703'"),
days: int = Field(default=30, description="Number of days to look back (7, 30, 90, etc.)")
) -> str:
"""
Get ad performance metrics for the specified time period.
RECOMMENDED WORKFLOW:
1. First run list_accounts() to get available account IDs
2. Then run get_account_currency() to see what currency the account uses
3. Finally run this command to get ad performance
Args:
customer_id: The Google Ads customer ID as a string (10 digits, no dashes)
days: Number of days to look back (default: 30)
Returns:
Formatted table of ad performance data
Note:
Cost values are in micros (millionths) of the account currency
(e.g., 1000000 = 1 USD in a USD account)
Example:
customer_id: "1234567890"
days: 14
"""
query = f"""
SELECT
ad_group_ad.ad.id,
ad_group_ad.ad.name,
ad_group_ad.status,
campaign.name,
ad_group.name,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM ad_group_ad
WHERE segments.date DURING LAST_{days}_DAYS
ORDER BY metrics.impressions DESC
LIMIT 50
"""
return await execute_gaql_query(customer_id, query)
@mcp.tool()
async def run_gaql(
customer_id: str = Field(description="Google Ads customer ID (10 digits, no dashes). Example: '9873186703'"),
query: str = Field(description="Valid GAQL query string following Google Ads Query Language syntax"),
format: str = Field(default="table", description="Output format: 'table', 'json', or 'csv'")
) -> str:
"""
Execute any arbitrary GAQL (Google Ads Query Language) query with custom formatting options.
This is the most powerful tool for custom Google Ads data queries.
Args:
customer_id: The Google Ads customer ID as a string (10 digits, no dashes)
query: The GAQL query to execute (any valid GAQL query)
format: Output format ("table", "json", or "csv")
Returns:
Query results in the requested format
EXAMPLE QUERIES:
1. Basic campaign metrics:
SELECT
campaign.name,
metrics.clicks,
metrics.impressions,
metrics.cost_micros
FROM campaign
WHERE segments.date DURING LAST_7_DAYS
2. Ad group performance:
SELECT
ad_group.name,
metrics.conversions,
metrics.cost_micros,
campaign.name
FROM ad_group
WHERE metrics.clicks > 100
3. Keyword analysis:
SELECT
keyword.text,
metrics.average_position,
metrics.ctr
FROM keyword_view
ORDER BY metrics.impressions DESC
4. Get conversion data:
SELECT
campaign.name,
metrics.conversions,
metrics.conversions_value,
metrics.cost_micros
FROM campaign
WHERE segments.date DURING LAST_30_DAYS
Note:
Cost values are in micros (millionths) of the account currency
(e.g., 1000000 = 1 USD in a USD account)
"""
try:
creds = get_credentials()
headers = get_headers(creds)
formatted_customer_id = format_customer_id(customer_id)
url = f"https://googleads.googleapis.com/{API_VERSION}/customers/{formatted_customer_id}/googleAds:search"
payload = {"query": query}
response = requests.post(url, headers=headers, json=payload)
if response.status_code != 200:
return f"Error executing query: {response.text}"
results = response.json()
if not results.get('results'):
return "No results found for the query."
if format.lower() == "json":
return json.dumps(results, indent=2)
elif format.lower() == "csv":
# Get field names from the first result
fields = []
first_result = results['results'][0]
for key, value in first_result.items():
if isinstance(value, dict):
for subkey in value:
fields.append(f"{key}.{subkey}")
else:
fields.append(key)
# Create CSV string
csv_lines = [",".join(fields)]
for result in results['results']:
row_data = []
for field in fields:
if "." in field:
parent, child = field.split(".")
value = str(result.get(parent, {}).get(child, "")).replace(",", ";")
else:
value = str(result.get(field, "")).replace(",", ";")
row_data.append(value)
csv_lines.append(",".join(row_data))
return "\n".join(csv_lines)
else: # default table format
result_lines = [f"Query Results for Account {formatted_customer_id}:"]
result_lines.append("-" * 100)
# Get field names and maximum widths
fields = []
field_widths = {}
first_result = results['results'][0]
for key, value in first_result.items():
if isinstance(value, dict):
for subkey in value:
field = f"{key}.{subkey}"
fields.append(field)
field_widths[field] = len(field)
else:
fields.append(key)
field_widths[key] = len(key)
# Calculate maximum field widths
for result in results['results']:
for field in fields:
if "." in field:
parent, child = field.split(".")
value = str(result.get(parent, {}).get(child, ""))
else:
value = str(result.get(field, ""))
field_widths[field] = max(field_widths[field], len(value))
# Create formatted header
header = " | ".join(f"{field:{field_widths[field]}}" for field in fields)
result_lines.append(header)
result_lines.append("-" * len(header))
# Add data rows
for result in results['results']:
row_data = []
for field in fields:
if "." in field:
parent, child = field.split(".")
value = str(result.get(parent, {}).get(child, ""))
else:
value = str(result.get(field, ""))
row_data.append(f"{value:{field_widths[field]}}")
result_lines.append(" | ".join(row_data))
return "\n".join(result_lines)
except Exception as e:
return f"Error executing GAQL query: {str(e)}"
@mcp.tool()
async def get_ad_creatives(
customer_id: str = Field(description="Google Ads customer ID (10 digits, no dashes). Example: '9873186703'")
) -> str:
"""
Get ad creative details including headlines, descriptions, and URLs.
This tool retrieves the actual ad content (headlines, descriptions)
for review and analysis. Great for creative audits.
RECOMMENDED WORKFLOW:
1. First run list_accounts() to get available account IDs
2. Then run this command with the desired account ID
Args:
customer_id: The Google Ads customer ID as a string (10 digits, no dashes)
Returns:
Formatted list of ad creative details
Example:
customer_id: "1234567890"
"""
query = """
SELECT
ad_group_ad.ad.id,
ad_group_ad.ad.name,
ad_group_ad.ad.type,
ad_group_ad.ad.final_urls,
ad_group_ad.status,
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
ad_group.name,
campaign.name
FROM ad_group_ad
WHERE ad_group_ad.status != 'REMOVED'
ORDER BY campaign.name, ad_group.name
LIMIT 50
"""
try:
creds = get_credentials()
headers = get_headers(creds)
formatted_customer_id = format_customer_id(customer_id)
url = f"https://googleads.googleapis.com/{API_VERSION}/customers/{formatted_customer_id}/googleAds:search"
payload = {"query": query}
response = requests.post(url, headers=headers, json=payload)
if response.status_code != 200:
return f"Error retrieving ad creatives: {response.text}"
results = response.json()
if not results.get('results'):
return "No ad creatives found for this customer ID."
# Format the results in a readable way
output_lines = [f"Ad Creatives for Customer ID {formatted_customer_id}:"]
output_lines.append("=" * 80)
for i, result in enumerate(results['results'], 1):
ad = result.get('adGroupAd', {}).get('ad', {})
ad_group = result.get('adGroup', {})
campaign = result.get('campaign', {})
output_lines.append(f"\n{i}. Campaign: {campaign.get('name', 'N/A')}")
output_lines.append(f" Ad Group: {ad_group.get('name', 'N/A')}")
output_lines.append(f" Ad ID: {ad.get('id', 'N/A')}")
output_lines.append(f" Ad Name: {ad.get('name', 'N/A')}")
output_lines.append(f" Status: {result.get('adGroupAd', {}).get('status', 'N/A')}")
output_lines.append(f" Type: {ad.get('type', 'N/A')}")
# Handle Responsive Search Ads
rsa = ad.get('responsiveSearchAd', {})
if rsa:
if 'headlines' in rsa:
output_lines.append(" Headlines:")
for headline in rsa['headlines']:
output_lines.append(f" - {headline.get('text', 'N/A')}")
if 'descriptions' in rsa:
output_lines.append(" Descriptions:")
for desc in rsa['descriptions']:
output_lines.append(f" - {desc.get('text', 'N/A')}")
# Handle Final URLs
final_urls = ad.get('finalUrls', [])
if final_urls:
output_lines.append(f" Final URLs: {', '.join(final_urls)}")
output_lines.append("-" * 80)
return "\n".join(output_lines)
except Exception as e:
return f"Error retrieving ad creatives: {str(e)}"
@mcp.tool()
async def get_account_currency(
customer_id: str = Field(description="Google Ads customer ID (10 digits, no dashes). Example: '9873186703'")
) -> str:
"""
Retrieve the default currency code used by the Google Ads account.
IMPORTANT: Run this first before analyzing cost data to understand which currency
the account uses. Cost values are always displayed in the account's currency.
Args:
customer_id: The Google Ads customer ID as a string (10 digits, no dashes)
Returns:
The account's default currency code (e.g., 'USD', 'EUR', 'GBP')
Example:
customer_id: "1234567890"
"""
query = """
SELECT
customer.id,
customer.currency_code
FROM customer
LIMIT 1
"""
try:
creds = get_credentials()
# Force refresh if needed
if not creds.valid:
logger.info("Credentials not valid, attempting refresh...")
if hasattr(creds, 'refresh_token') and creds.refresh_token:
creds.refresh(Request())
logger.info("Credentials refreshed successfully")
else:
raise ValueError("Invalid credentials and no refresh token available")
headers = get_headers(creds)
formatted_customer_id = format_customer_id(customer_id)
url = f"https://googleads.googleapis.com/{API_VERSION}/customers/{formatted_customer_id}/googleAds:search"
payload = {"query": query}
response = requests.post(url, headers=headers, json=payload)
if response.status_code != 200:
return f"Error retrieving account currency: {response.text}"
results = response.json()
if not results.get('results'):
return "No account information found for this customer ID."
# Extract the currency code from the results
customer = results['results'][0].get('customer', {})
currency_code = customer.get('currencyCode', 'Not specified')
return f"Account {formatted_customer_id} uses currency: {currency_code}"
except Exception as e:
logger.error(f"Error retrieving account currency: {str(e)}")
return f"Error retrieving account currency: {str(e)}"
@mcp.resource("gaql://reference")
def gaql_reference() -> str:
"""Google Ads Query Language (GAQL) reference documentation."""
return """
# Google Ads Query Language (GAQL) Reference
GAQL is similar to SQL but with specific syntax for Google Ads. Here's a quick reference:
## Basic Query Structure
```
SELECT field1, field2, ...
FROM resource_type
WHERE condition
ORDER BY field [ASC|DESC]
LIMIT n
```
## Common Field Types
### Resource Fields
- campaign.id, campaign.name, campaign.status
- ad_group.id, ad_group.name, ad_group.status
- ad_group_ad.ad.id, ad_group_ad.ad.final_urls
- keyword.text, keyword.match_type
### Metric Fields
- metrics.impressions
- metrics.clicks
- metrics.cost_micros
- metrics.conversions
- metrics.ctr
- metrics.average_cpc
### Segment Fields
- segments.date
- segments.device
- segments.day_of_week
## Common WHERE Clauses
### Date Ranges
- WHERE segments.date DURING LAST_7_DAYS
- WHERE segments.date DURING LAST_30_DAYS
- WHERE segments.date BETWEEN '2023-01-01' AND '2023-01-31'
### Filtering
- WHERE campaign.status = 'ENABLED'
- WHERE metrics.clicks > 100
- WHERE campaign.name LIKE '%Brand%'
## Tips
- Always check account currency before analyzing cost data
- Cost values are in micros (millionths): 1000000 = 1 unit of currency
- Use LIMIT to avoid large result sets
"""
@mcp.prompt("google_ads_workflow")
def google_ads_workflow() -> str:
"""Provides guidance on the recommended workflow for using Google Ads tools."""
return """
I'll help you analyze your Google Ads account data. Here's the recommended workflow:
1. First, let's list all the accounts you have access to:
- Run the `list_accounts()` tool to get available account IDs
2. Before analyzing cost data, let's check which currency the account uses:
- Run `get_account_currency(customer_id="ACCOUNT_ID")` with your selected account
3. Now we can explore the account data:
- For campaign performance: `get_campaign_performance(customer_id="ACCOUNT_ID", days=30)`
- For ad performance: `get_ad_performance(customer_id="ACCOUNT_ID", days=30)`
- For ad creative review: `get_ad_creatives(customer_id="ACCOUNT_ID")`
4. For custom queries, use the GAQL query tool:
- `run_gaql(customer_id="ACCOUNT_ID", query="YOUR_QUERY", format="table")`
5. Let me know if you have specific questions about:
- Campaign performance
- Ad performance
- Keywords
- Budgets
- Conversions
Important: Always provide the customer_id as a string.
For example: customer_id="1234567890"
"""
@mcp.prompt("gaql_help")
def gaql_help() -> str:
"""Provides assistance for writing GAQL queries."""
return """
I'll help you write a Google Ads Query Language (GAQL) query. Here are some examples to get you started:
## Get campaign performance last 30 days
```
SELECT
campaign.id,
campaign.name,
campaign.status,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM campaign
WHERE segments.date DURING LAST_30_DAYS
ORDER BY metrics.cost_micros DESC
```
## Get keyword performance
```
SELECT
keyword.text,
keyword.match_type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM keyword_view
WHERE segments.date DURING LAST_30_DAYS
ORDER BY metrics.clicks DESC
```
## Get ads with poor performance
```
SELECT
ad_group_ad.ad.id,
ad_group_ad.ad.name,
campaign.name,
ad_group.name,
metrics.impressions,
metrics.clicks,
metrics.conversions
FROM ad_group_ad
WHERE
segments.date DURING LAST_30_DAYS
AND metrics.impressions > 1000
AND metrics.ctr < 0.01
ORDER BY metrics.impressions DESC
```
Once you've chosen a query, use it with:
```
run_gaql(customer_id="YOUR_ACCOUNT_ID", query="YOUR_QUERY_HERE")
```
Remember:
- Always provide the customer_id as a string
- Cost values are in micros (1,000,000 = 1 unit of currency)
- Use LIMIT to avoid large result sets
- Check the account currency before analyzing cost data
"""
@mcp.tool()
async def get_image_assets(
customer_id: str = Field(description="Google Ads customer ID (10 digits, no dashes). Example: '9873186703'"),
limit: int = Field(default=50, description="Maximum number of image assets to return")
) -> str:
"""
Retrieve all image assets in the account including their full-size URLs.
This tool allows you to get details about image assets used in your Google Ads account,
including the URLs to download the full-size images for further processing or analysis.
RECOMMENDED WORKFLOW:
1. First run list_accounts() to get available account IDs
2. Then run this command with the desired account ID
Args:
customer_id: The Google Ads customer ID as a string (10 digits, no dashes)
limit: Maximum number of image assets to return (default: 50)
Returns:
Formatted list of image assets with their download URLs
Example:
customer_id: "1234567890"
limit: 100
"""
query = f"""
SELECT
asset.id,
asset.name,
asset.type,
asset.image_asset.full_size.url,
asset.image_asset.full_size.height_pixels,
asset.image_asset.full_size.width_pixels,
asset.image_asset.file_size
FROM
asset
WHERE
asset.type = 'IMAGE'
LIMIT {limit}
"""
try:
creds = get_credentials()
headers = get_headers(creds)
formatted_customer_id = format_customer_id(customer_id)
url = f"https://googleads.googleapis.com/{API_VERSION}/customers/{formatted_customer_id}/googleAds:search"
payload = {"query": query}
response = requests.post(url, headers=headers, json=payload)
if response.status_code != 200:
return f"Error retrieving image assets: {response.text}"
results = response.json()
if not results.get('results'):
return "No image assets found for this customer ID."
# Format the results in a readable way
output_lines = [f"Image Assets for Customer ID {formatted_customer_id}:"]
output_lines.append("=" * 80)
for i, result in enumerate(results['results'], 1):
asset = result.get('asset', {})
image_asset = asset.get('imageAsset', {})
full_size = image_asset.get('fullSize', {})
output_lines.append(f"\n{i}. Asset ID: {asset.get('id', 'N/A')}")
output_lines.append(f" Name: {asset.get('name', 'N/A')}")
if full_size:
output_lines.append(f" Image URL: {full_size.get('url', 'N/A')}")
output_lines.append(f" Dimensions: {full_size.get('widthPixels', 'N/A')} x {full_size.get('heightPixels', 'N/A')} px")
file_size = image_asset.get('fileSize', 'N/A')
if file_size != 'N/A':
# Convert to KB for readability
file_size_kb = int(file_size) / 1024
output_lines.append(f" File Size: {file_size_kb:.2f} KB")