meta-ads-mcp-python 1.0.79__py3-none-any.whl

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.
@@ -0,0 +1,232 @@
1
+ """Meta Business Manager (MCC) functionality for Meta Ads API.
2
+
3
+ Provides tools for managing multi-client accounts via Meta Business Manager,
4
+ which is the Meta equivalent of Google's MCC (My Client Center).
5
+ """
6
+
7
+ import json
8
+ from typing import Optional
9
+ from .api import meta_api_tool, make_api_request
10
+ from .server import mcp_server
11
+
12
+
13
+ # Fields requested for ad accounts to keep responses consistent with accounts.py
14
+ _AD_ACCOUNT_FIELDS = (
15
+ "id,name,account_id,account_status,amount_spent,balance,currency,"
16
+ "age,business_city,business_country_code,timezone_name"
17
+ )
18
+
19
+ # Fields requested for Business Manager objects
20
+ _BUSINESS_FIELDS = (
21
+ "id,name,timezone_id,business_type,verification_status,profile_picture_uri"
22
+ )
23
+
24
+
25
+ def _normalize_account_monetary_fields(account: dict) -> dict:
26
+ """Convert amount_spent and balance from cents to currency units."""
27
+ _ZERO_DECIMAL = {
28
+ "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA",
29
+ "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF",
30
+ }
31
+ currency = account.get("currency", "USD")
32
+ for field in ("amount_spent", "balance"):
33
+ if field in account:
34
+ try:
35
+ amount_int = int(account[field])
36
+ account[field] = (
37
+ str(amount_int)
38
+ if currency.upper() in _ZERO_DECIMAL
39
+ else f"{amount_int / 100:.2f}"
40
+ )
41
+ except (TypeError, ValueError):
42
+ pass
43
+ return account
44
+
45
+
46
+ @mcp_server.tool()
47
+ @meta_api_tool
48
+ async def get_businesses(
49
+ access_token: Optional[str] = None,
50
+ limit: int = 100,
51
+ ) -> str:
52
+ """Get all Business Manager accounts (MCCs) accessible by the current user.
53
+
54
+ Returns a list of Business Manager organizations the authenticated user
55
+ belongs to. Each entry includes the business ID and name, which can be
56
+ used with other MCC tools to drill into client accounts.
57
+
58
+ Args:
59
+ access_token: Meta API access token (optional -- uses cached token).
60
+ limit: Maximum number of businesses to return (default: 100).
61
+ """
62
+ data = await make_api_request(
63
+ "me/businesses",
64
+ access_token,
65
+ {"fields": _BUSINESS_FIELDS, "limit": limit},
66
+ )
67
+ return json.dumps(data, indent=2)
68
+
69
+
70
+ @mcp_server.tool()
71
+ @meta_api_tool
72
+ async def get_business_ad_accounts(
73
+ business_id: str,
74
+ access_token: Optional[str] = None,
75
+ include_owned: bool = True,
76
+ include_client: bool = True,
77
+ limit: int = 200,
78
+ ) -> str:
79
+ """Get all ad accounts under a Business Manager (owned and/or client accounts).
80
+
81
+ Fetches ad accounts from a Meta Business Manager. Owned accounts are those
82
+ the business directly controls; client accounts are managed on behalf of
83
+ external advertisers.
84
+
85
+ Args:
86
+ business_id: Meta Business Manager ID.
87
+ access_token: Meta API access token (optional -- uses cached token).
88
+ include_owned: Include ad accounts owned by this business (default: True).
89
+ include_client: Include client ad accounts managed by this business (default: True).
90
+ limit: Maximum number of accounts per category (default: 200).
91
+ """
92
+ if not business_id:
93
+ return json.dumps({"error": "business_id is required"}, indent=2)
94
+
95
+ params = {"fields": _AD_ACCOUNT_FIELDS, "limit": limit}
96
+ owned: list = []
97
+ clients: list = []
98
+ errors: list = []
99
+
100
+ if include_owned:
101
+ result = await make_api_request(
102
+ f"{business_id}/owned_ad_accounts", access_token, params
103
+ )
104
+ if "error" in result:
105
+ errors.append({"source": "owned_ad_accounts", "error": result["error"]})
106
+ else:
107
+ owned = [_normalize_account_monetary_fields(a) for a in result.get("data", [])]
108
+
109
+ if include_client:
110
+ result = await make_api_request(
111
+ f"{business_id}/client_ad_accounts", access_token, params
112
+ )
113
+ if "error" in result:
114
+ errors.append({"source": "client_ad_accounts", "error": result["error"]})
115
+ else:
116
+ clients = [_normalize_account_monetary_fields(a) for a in result.get("data", [])]
117
+
118
+ response: dict = {
119
+ "business_id": business_id,
120
+ "owned_accounts": owned,
121
+ "owned_count": len(owned),
122
+ "client_accounts": clients,
123
+ "client_count": len(clients),
124
+ "total_count": len(owned) + len(clients),
125
+ }
126
+ if errors:
127
+ response["errors"] = errors
128
+
129
+ return json.dumps(response, indent=2)
130
+
131
+
132
+ @mcp_server.tool()
133
+ @meta_api_tool
134
+ async def find_client_account(
135
+ business_id: str,
136
+ query: str,
137
+ access_token: Optional[str] = None,
138
+ search_owned: bool = True,
139
+ search_client: bool = True,
140
+ limit: int = 200,
141
+ ) -> str:
142
+ """Find a client's ad account within a Business Manager by name or account ID.
143
+
144
+ Searches through all ad accounts under the specified Business Manager and
145
+ returns those whose name contains the query string (case-insensitive) or
146
+ whose account ID / act_ID matches exactly.
147
+
148
+ Args:
149
+ business_id: Meta Business Manager ID to search within.
150
+ query: Account name substring (case-insensitive) or exact account ID
151
+ (with or without the "act_" prefix) to match.
152
+ access_token: Meta API access token (optional -- uses cached token).
153
+ search_owned: Search owned ad accounts (default: True).
154
+ search_client: Search client ad accounts (default: True).
155
+ limit: Maximum number of accounts to fetch before filtering (default: 200).
156
+ """
157
+ if not business_id:
158
+ return json.dumps({"error": "business_id is required"}, indent=2)
159
+ if not query:
160
+ return json.dumps({"error": "query is required"}, indent=2)
161
+
162
+ params = {"fields": _AD_ACCOUNT_FIELDS, "limit": limit}
163
+ all_accounts: list = []
164
+ errors: list = []
165
+
166
+ if search_owned:
167
+ result = await make_api_request(
168
+ f"{business_id}/owned_ad_accounts", access_token, params
169
+ )
170
+ if "error" in result:
171
+ errors.append({"source": "owned_ad_accounts", "error": result["error"]})
172
+ else:
173
+ for acc in result.get("data", []):
174
+ acc["_account_type"] = "owned"
175
+ all_accounts.append(_normalize_account_monetary_fields(acc))
176
+
177
+ if search_client:
178
+ result = await make_api_request(
179
+ f"{business_id}/client_ad_accounts", access_token, params
180
+ )
181
+ if "error" in result:
182
+ errors.append({"source": "client_ad_accounts", "error": result["error"]})
183
+ else:
184
+ for acc in result.get("data", []):
185
+ acc["_account_type"] = "client"
186
+ all_accounts.append(_normalize_account_monetary_fields(acc))
187
+
188
+ # Normalize query for matching
189
+ q_lower = query.lower().strip()
190
+ q_id = q_lower.lstrip("act_") # strip act_ prefix for numeric ID comparison
191
+
192
+ matches = [
193
+ acc for acc in all_accounts
194
+ if q_lower in acc.get("name", "").lower()
195
+ or acc.get("account_id", "") == q_id
196
+ or acc.get("id", "").lstrip("act_") == q_id
197
+ ]
198
+
199
+ response: dict = {
200
+ "business_id": business_id,
201
+ "query": query,
202
+ "matches": matches,
203
+ "match_count": len(matches),
204
+ "searched_total": len(all_accounts),
205
+ }
206
+ if errors:
207
+ response["errors"] = errors
208
+
209
+ return json.dumps(response, indent=2)
210
+
211
+
212
+ @mcp_server.tool()
213
+ @meta_api_tool
214
+ async def get_business_details(
215
+ business_id: str,
216
+ access_token: Optional[str] = None,
217
+ ) -> str:
218
+ """Get detailed information about a specific Business Manager.
219
+
220
+ Args:
221
+ business_id: Meta Business Manager ID.
222
+ access_token: Meta API access token (optional -- uses cached token).
223
+ """
224
+ if not business_id:
225
+ return json.dumps({"error": "business_id is required"}, indent=2)
226
+
227
+ data = await make_api_request(
228
+ business_id,
229
+ access_token,
230
+ {"fields": f"{_BUSINESS_FIELDS},owned_ad_accounts.limit(1){{id}},client_ad_accounts.limit(1){{id}}"},
231
+ )
232
+ return json.dumps(data, indent=2)
@@ -0,0 +1,418 @@
1
+ """OpenAI MCP Deep Research tools for Meta Ads API.
2
+
3
+ This module implements the required 'search' and 'fetch' tools for OpenAI's
4
+ ChatGPT Deep Research feature, providing access to Meta Ads data in the format
5
+ expected by ChatGPT.
6
+
7
+ The tools expose Meta Ads data (accounts, campaigns, ads, etc.) as searchable
8
+ and fetchable records for ChatGPT Deep Research analysis.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ from typing import List, Dict, Any, Optional
14
+ from .api import meta_api_tool, make_api_request, ensure_act_prefix
15
+ from .server import mcp_server
16
+ from .utils import logger
17
+
18
+
19
+ class MetaAdsDataManager:
20
+ """Manages Meta Ads data for OpenAI MCP search and fetch operations"""
21
+
22
+ def __init__(self):
23
+ self._cache = {}
24
+ logger.debug("MetaAdsDataManager initialized")
25
+
26
+ async def _get_ad_accounts(self, access_token: str, limit: int = 200) -> List[Dict[str, Any]]:
27
+ """Get ad accounts data"""
28
+ try:
29
+ endpoint = "me/adaccounts"
30
+ params = {
31
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,business_city,business_country_code",
32
+ "limit": limit
33
+ }
34
+
35
+ data = await make_api_request(endpoint, access_token, params)
36
+
37
+ if "data" in data:
38
+ return data["data"]
39
+ return []
40
+ except Exception as e:
41
+ logger.error(f"Error fetching ad accounts: {e}")
42
+ return []
43
+
44
+ async def _get_campaigns(self, access_token: str, account_id: str, limit: int = 25) -> List[Dict[str, Any]]:
45
+ """Get campaigns data for an account"""
46
+ try:
47
+ endpoint = f"{account_id}/campaigns"
48
+ params = {
49
+ "fields": "id,name,status,objective,daily_budget,lifetime_budget,start_time,stop_time,created_time,updated_time",
50
+ "limit": limit
51
+ }
52
+
53
+ data = await make_api_request(endpoint, access_token, params)
54
+
55
+ if "data" in data:
56
+ return data["data"]
57
+ return []
58
+ except Exception as e:
59
+ logger.error(f"Error fetching campaigns for {account_id}: {e}")
60
+ return []
61
+
62
+ async def _get_ads(self, access_token: str, account_id: str, limit: int = 25) -> List[Dict[str, Any]]:
63
+ """Get ads data for an account"""
64
+ try:
65
+ endpoint = f"{account_id}/ads"
66
+ params = {
67
+ "fields": "id,name,status,creative,targeting,bid_amount,created_time,updated_time",
68
+ "limit": limit
69
+ }
70
+
71
+ data = await make_api_request(endpoint, access_token, params)
72
+
73
+ if "data" in data:
74
+ return data["data"]
75
+ return []
76
+ except Exception as e:
77
+ logger.error(f"Error fetching ads for {account_id}: {e}")
78
+ return []
79
+
80
+ async def _get_pages_for_account(self, access_token: str, account_id: str) -> List[Dict[str, Any]]:
81
+ """Get pages associated with an account"""
82
+ try:
83
+ # Import the page discovery function from ads module
84
+ from .ads import _discover_pages_for_account
85
+
86
+ account_id = ensure_act_prefix(account_id)
87
+
88
+ page_discovery_result = await _discover_pages_for_account(account_id, access_token)
89
+
90
+ if not page_discovery_result.get("success"):
91
+ return []
92
+
93
+ # Return page data in a consistent format
94
+ return [{
95
+ "id": page_discovery_result["page_id"],
96
+ "name": page_discovery_result.get("page_name", "Unknown"),
97
+ "source": page_discovery_result.get("source", "unknown"),
98
+ "account_id": account_id
99
+ }]
100
+ except Exception as e:
101
+ logger.error(f"Error fetching pages for {account_id}: {e}")
102
+ return []
103
+
104
+ async def _get_businesses(self, access_token: str, user_id: str = "me", limit: int = 25) -> List[Dict[str, Any]]:
105
+ """Get businesses accessible by the current user"""
106
+ try:
107
+ endpoint = f"{user_id}/businesses"
108
+ params = {
109
+ "fields": "id,name,created_time,verification_status",
110
+ "limit": limit
111
+ }
112
+
113
+ data = await make_api_request(endpoint, access_token, params)
114
+
115
+ if "data" in data:
116
+ return data["data"]
117
+ return []
118
+ except Exception as e:
119
+ logger.error(f"Error fetching businesses: {e}")
120
+ return []
121
+
122
+ async def search_records(self, query: str, access_token: str) -> List[str]:
123
+ """Search Meta Ads data and return matching record IDs
124
+
125
+ Args:
126
+ query: Search query string
127
+ access_token: Meta API access token
128
+
129
+ Returns:
130
+ List of record IDs that match the query
131
+ """
132
+ logger.info(f"Searching Meta Ads data with query: {query}")
133
+
134
+ # Normalize query for matching
135
+ query_lower = query.lower()
136
+ query_terms = re.findall(r'\w+', query_lower)
137
+
138
+ matching_ids = []
139
+
140
+ try:
141
+ # Search ad accounts
142
+ accounts = await self._get_ad_accounts(access_token, limit=200)
143
+ for account in accounts:
144
+ account_text = f"{account.get('name', '')} {account.get('id', '')} {account.get('account_status', '')} {account.get('business_city', '')} {account.get('business_country_code', '')}".lower()
145
+
146
+ if any(term in account_text for term in query_terms):
147
+ record_id = f"account:{account['id']}"
148
+ matching_ids.append(record_id)
149
+
150
+ # Cache the account data
151
+ self._cache[record_id] = {
152
+ "id": record_id,
153
+ "type": "account",
154
+ "title": f"Ad Account: {account.get('name', 'Unnamed Account')}",
155
+ "text": f"Meta Ads Account {account.get('name', 'Unnamed')} (ID: {account.get('id', 'N/A')}) - Status: {account.get('account_status', 'Unknown')}, Currency: {account.get('currency', 'Unknown')}, Spent: ${account.get('amount_spent', 0)}, Balance: ${account.get('balance', 0)}",
156
+ "metadata": {
157
+ "account_id": account.get('id'),
158
+ "account_name": account.get('name'),
159
+ "status": account.get('account_status'),
160
+ "currency": account.get('currency'),
161
+ "business_location": f"{account.get('business_city', '')}, {account.get('business_country_code', '')}".strip(', '),
162
+ "data_type": "meta_ads_account"
163
+ },
164
+ "raw_data": account
165
+ }
166
+
167
+ # Also search campaigns for this account if it matches
168
+ campaigns = await self._get_campaigns(access_token, account['id'], limit=10)
169
+ for campaign in campaigns:
170
+ campaign_text = f"{campaign.get('name', '')} {campaign.get('objective', '')} {campaign.get('status', '')}".lower()
171
+
172
+ if any(term in campaign_text for term in query_terms):
173
+ campaign_record_id = f"campaign:{campaign['id']}"
174
+ matching_ids.append(campaign_record_id)
175
+
176
+ # Cache the campaign data
177
+ self._cache[campaign_record_id] = {
178
+ "id": campaign_record_id,
179
+ "type": "campaign",
180
+ "title": f"Campaign: {campaign.get('name', 'Unnamed Campaign')}",
181
+ "text": f"Meta Ads Campaign {campaign.get('name', 'Unnamed')} (ID: {campaign.get('id', 'N/A')}) - Objective: {campaign.get('objective', 'Unknown')}, Status: {campaign.get('status', 'Unknown')}, Daily Budget: ${campaign.get('daily_budget', 'Not set')}, Account: {account.get('name', 'Unknown')}",
182
+ "metadata": {
183
+ "campaign_id": campaign.get('id'),
184
+ "campaign_name": campaign.get('name'),
185
+ "objective": campaign.get('objective'),
186
+ "status": campaign.get('status'),
187
+ "account_id": account.get('id'),
188
+ "account_name": account.get('name'),
189
+ "data_type": "meta_ads_campaign"
190
+ },
191
+ "raw_data": campaign
192
+ }
193
+
194
+ # If query specifically mentions "ads" or "ad", also search individual ads
195
+ if any(term in ['ad', 'ads', 'advertisement', 'creative'] for term in query_terms):
196
+ for account in accounts[:3]: # Limit to first 3 accounts for performance
197
+ ads = await self._get_ads(access_token, account['id'], limit=10)
198
+ for ad in ads:
199
+ ad_text = f"{ad.get('name', '')} {ad.get('status', '')}".lower()
200
+
201
+ if any(term in ad_text for term in query_terms):
202
+ ad_record_id = f"ad:{ad['id']}"
203
+ matching_ids.append(ad_record_id)
204
+
205
+ # Cache the ad data
206
+ self._cache[ad_record_id] = {
207
+ "id": ad_record_id,
208
+ "type": "ad",
209
+ "title": f"Ad: {ad.get('name', 'Unnamed Ad')}",
210
+ "text": f"Meta Ad {ad.get('name', 'Unnamed')} (ID: {ad.get('id', 'N/A')}) - Status: {ad.get('status', 'Unknown')}, Bid Amount: ${ad.get('bid_amount', 'Not set')}, Account: {account.get('name', 'Unknown')}",
211
+ "metadata": {
212
+ "ad_id": ad.get('id'),
213
+ "ad_name": ad.get('name'),
214
+ "status": ad.get('status'),
215
+ "account_id": account.get('id'),
216
+ "account_name": account.get('name'),
217
+ "data_type": "meta_ads_ad"
218
+ },
219
+ "raw_data": ad
220
+ }
221
+
222
+ # If query specifically mentions "page" or "pages", also search pages
223
+ if any(term in ['page', 'pages', 'facebook page'] for term in query_terms):
224
+ for account in accounts[:5]: # Limit to first 5 accounts for performance
225
+ pages = await self._get_pages_for_account(access_token, account['id'])
226
+ for page in pages:
227
+ page_text = f"{page.get('name', '')} {page.get('source', '')}".lower()
228
+
229
+ if any(term in page_text for term in query_terms):
230
+ page_record_id = f"page:{page['id']}"
231
+ matching_ids.append(page_record_id)
232
+
233
+ # Cache the page data
234
+ self._cache[page_record_id] = {
235
+ "id": page_record_id,
236
+ "type": "page",
237
+ "title": f"Facebook Page: {page.get('name', 'Unnamed Page')}",
238
+ "text": f"Facebook Page {page.get('name', 'Unnamed')} (ID: {page.get('id', 'N/A')}) - Source: {page.get('source', 'Unknown')}, Account: {account.get('name', 'Unknown')}",
239
+ "metadata": {
240
+ "page_id": page.get('id'),
241
+ "page_name": page.get('name'),
242
+ "source": page.get('source'),
243
+ "account_id": account.get('id'),
244
+ "account_name": account.get('name'),
245
+ "data_type": "meta_ads_page"
246
+ },
247
+ "raw_data": page
248
+ }
249
+
250
+ # If query specifically mentions "business" or "businesses", also search businesses
251
+ if any(term in ['business', 'businesses', 'company', 'companies'] for term in query_terms):
252
+ businesses = await self._get_businesses(access_token, limit=25)
253
+ for business in businesses:
254
+ business_text = f"{business.get('name', '')} {business.get('verification_status', '')}".lower()
255
+
256
+ if any(term in business_text for term in query_terms):
257
+ business_record_id = f"business:{business['id']}"
258
+ matching_ids.append(business_record_id)
259
+
260
+ # Cache the business data
261
+ self._cache[business_record_id] = {
262
+ "id": business_record_id,
263
+ "type": "business",
264
+ "title": f"Business: {business.get('name', 'Unnamed Business')}",
265
+ "text": f"Meta Business {business.get('name', 'Unnamed')} (ID: {business.get('id', 'N/A')}) - Created: {business.get('created_time', 'Unknown')}, Verification: {business.get('verification_status', 'Unknown')}",
266
+ "metadata": {
267
+ "business_id": business.get('id'),
268
+ "business_name": business.get('name'),
269
+ "created_time": business.get('created_time'),
270
+ "verification_status": business.get('verification_status'),
271
+ "data_type": "meta_ads_business"
272
+ },
273
+ "raw_data": business
274
+ }
275
+
276
+ except Exception as e:
277
+ logger.error(f"Error during search operation: {e}")
278
+ # Return empty list on error, but don't raise exception
279
+ return []
280
+
281
+ logger.info(f"Search completed. Found {len(matching_ids)} matching records")
282
+ return matching_ids[:50] # Limit to 50 results for performance
283
+
284
+ def fetch_record(self, record_id: str) -> Optional[Dict[str, Any]]:
285
+ """Fetch a cached record by ID
286
+
287
+ Args:
288
+ record_id: The record ID to fetch
289
+
290
+ Returns:
291
+ Record data or None if not found
292
+ """
293
+ logger.info(f"Fetching record: {record_id}")
294
+
295
+ record = self._cache.get(record_id)
296
+ if record:
297
+ logger.debug(f"Record found in cache: {record['type']}")
298
+ return record
299
+ else:
300
+ logger.warning(f"Record not found in cache: {record_id}")
301
+ return None
302
+
303
+
304
+ # Global data manager instance
305
+ _data_manager = MetaAdsDataManager()
306
+
307
+
308
+ @mcp_server.tool()
309
+ @meta_api_tool
310
+ async def search(
311
+ query: str,
312
+ access_token: Optional[str] = None
313
+ ) -> str:
314
+ """
315
+ Search through Meta Ads data and return matching record IDs.
316
+ It searches across ad accounts, campaigns, ads, pages, and businesses to find relevant records
317
+ based on the provided query.
318
+
319
+ Args:
320
+ query: Search query string to find relevant Meta Ads records
321
+ access_token: Meta API access token (optional - will use cached token if not provided)
322
+
323
+ Returns:
324
+ JSON response with list of matching record IDs
325
+
326
+ Example Usage:
327
+ search(query="active campaigns")
328
+ search(query="account spending")
329
+ search(query="facebook ads performance")
330
+ search(query="facebook pages")
331
+ search(query="user businesses")
332
+ """
333
+ if not query:
334
+ return json.dumps({
335
+ "error": "query parameter is required",
336
+ "ids": []
337
+ }, indent=2)
338
+
339
+ try:
340
+ # Use the data manager to search records
341
+ matching_ids = await _data_manager.search_records(query, access_token)
342
+
343
+ response = {
344
+ "ids": matching_ids,
345
+ "query": query,
346
+ "total_results": len(matching_ids)
347
+ }
348
+
349
+ logger.info(f"Search successful. Query: '{query}', Results: {len(matching_ids)}")
350
+ return json.dumps(response, indent=2)
351
+
352
+ except Exception as e:
353
+ error_msg = str(e)
354
+ logger.error(f"Error in search tool: {error_msg}")
355
+
356
+ return json.dumps({
357
+ "error": "Failed to search Meta Ads data",
358
+ "details": error_msg,
359
+ "ids": [],
360
+ "query": query
361
+ }, indent=2)
362
+
363
+
364
+ @mcp_server.tool()
365
+ async def fetch(
366
+ id: str
367
+ ) -> str:
368
+ """
369
+ Fetch a record previously returned by the 'search' tool in the same session.
370
+
371
+ IMPORTANT LIMITATIONS:
372
+ - This tool ONLY returns records that were cached by a prior 'search' call.
373
+ It does NOT make direct API calls to Meta. If the record was not found by
374
+ 'search' first, this tool will return "Record not found".
375
+ - Do NOT use this tool to look up campaigns, adsets, or ads by ID directly.
376
+
377
+ For direct lookups by ID, use these tools instead:
378
+ - get_campaign_details(campaign_id=...) - for campaigns
379
+ - get_adset_details(adset_id=...) - for ad sets
380
+ - get_ads(account_id=..., campaign_id=...) - for ads
381
+ - get_adsets(account_id=..., campaign_id=...) - for ad sets in a campaign
382
+
383
+ Args:
384
+ id: The record ID to fetch (format: "type:id", e.g., "account:act_123456").
385
+ Must have been returned by a previous 'search' call.
386
+
387
+ Returns:
388
+ JSON response with record data, or "Record not found" if the record
389
+ was not previously cached by 'search'.
390
+ """
391
+ if not id:
392
+ return json.dumps({
393
+ "error": "id parameter is required"
394
+ }, indent=2)
395
+
396
+ try:
397
+ # Use the data manager to fetch the record
398
+ record = _data_manager.fetch_record(id)
399
+
400
+ if record:
401
+ logger.info(f"Record fetched successfully: {id}")
402
+ return json.dumps(record, indent=2)
403
+ else:
404
+ logger.warning(f"Record not found: {id}")
405
+ return json.dumps({
406
+ "error": f"Record not found: {id}",
407
+ "id": id
408
+ }, indent=2)
409
+
410
+ except Exception as e:
411
+ error_msg = str(e)
412
+ logger.error(f"Error in fetch tool: {error_msg}")
413
+
414
+ return json.dumps({
415
+ "error": "Failed to fetch record",
416
+ "details": error_msg,
417
+ "id": id
418
+ }, indent=2)