meta-ads-mcp 0.2.6__py3-none-any.whl → 0.2.9__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.
- meta_ads_mcp/__init__.py +1 -1
- meta_ads_mcp/api.py +122 -53
- meta_ads_mcp/core/__init__.py +2 -1
- meta_ads_mcp/core/ads.py +123 -192
- meta_ads_mcp/core/adsets.py +137 -11
- meta_ads_mcp/core/api.py +29 -1
- meta_ads_mcp/core/auth.py +83 -15
- meta_ads_mcp/core/authentication.py +103 -49
- meta_ads_mcp/core/campaigns.py +151 -14
- meta_ads_mcp/core/insights.py +18 -4
- meta_ads_mcp/core/pipeboard_auth.py +484 -0
- meta_ads_mcp/core/server.py +49 -4
- meta_ads_mcp/core/utils.py +11 -5
- {meta_ads_mcp-0.2.6.dist-info → meta_ads_mcp-0.2.9.dist-info}/METADATA +122 -31
- meta_ads_mcp-0.2.9.dist-info/RECORD +22 -0
- meta_ads_mcp-0.2.9.dist-info/licenses/LICENSE +201 -0
- meta_ads_mcp-0.2.6.dist-info/RECORD +0 -20
- {meta_ads_mcp-0.2.6.dist-info → meta_ads_mcp-0.2.9.dist-info}/WHEEL +0 -0
- {meta_ads_mcp-0.2.6.dist-info → meta_ads_mcp-0.2.9.dist-info}/entry_points.txt +0 -0
meta_ads_mcp/core/adsets.py
CHANGED
|
@@ -32,15 +32,23 @@ async def get_adsets(access_token: str = None, account_id: str = None, limit: in
|
|
|
32
32
|
else:
|
|
33
33
|
return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
|
|
34
34
|
|
|
35
|
-
endpoint
|
|
36
|
-
params = {
|
|
37
|
-
"fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,frequency_control_specs{event,interval_days,max_frequency}",
|
|
38
|
-
"limit": limit
|
|
39
|
-
}
|
|
40
|
-
|
|
35
|
+
# Change endpoint based on whether campaign_id is provided
|
|
41
36
|
if campaign_id:
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
endpoint = f"{campaign_id}/adsets"
|
|
38
|
+
params = {
|
|
39
|
+
"fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,frequency_control_specs{event,interval_days,max_frequency}",
|
|
40
|
+
"limit": limit
|
|
41
|
+
}
|
|
42
|
+
else:
|
|
43
|
+
# Use account endpoint if no campaign_id is given
|
|
44
|
+
endpoint = f"{account_id}/adsets"
|
|
45
|
+
params = {
|
|
46
|
+
"fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,frequency_control_specs{event,interval_days,max_frequency}",
|
|
47
|
+
"limit": limit
|
|
48
|
+
}
|
|
49
|
+
# Note: Removed the attempt to add campaign_id to params for the account endpoint case,
|
|
50
|
+
# as it was ineffective and the logic now uses the correct endpoint for campaign filtering.
|
|
51
|
+
|
|
44
52
|
data = await make_api_request(endpoint, access_token, params)
|
|
45
53
|
|
|
46
54
|
return json.dumps(data, indent=2)
|
|
@@ -66,19 +74,133 @@ async def get_adset_details(access_token: str = None, adset_id: str = None) -> s
|
|
|
66
74
|
return json.dumps({"error": "No ad set ID provided"}, indent=2)
|
|
67
75
|
|
|
68
76
|
endpoint = f"{adset_id}"
|
|
77
|
+
# Explicitly prioritize frequency_control_specs in the fields request
|
|
69
78
|
params = {
|
|
70
|
-
"fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,attribution_spec,destination_type,promoted_object,pacing_type,budget_remaining
|
|
79
|
+
"fields": "id,name,campaign_id,status,frequency_control_specs{event,interval_days,max_frequency},daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,attribution_spec,destination_type,promoted_object,pacing_type,budget_remaining"
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
data = await make_api_request(endpoint, access_token, params)
|
|
74
83
|
|
|
84
|
+
# For debugging - check if frequency_control_specs was returned
|
|
85
|
+
if 'frequency_control_specs' not in data:
|
|
86
|
+
data['_meta'] = {
|
|
87
|
+
'note': 'No frequency_control_specs field was returned by the API. This means either no frequency caps are set or the API did not include this field in the response.'
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
return json.dumps(data, indent=2)
|
|
76
91
|
|
|
77
92
|
|
|
93
|
+
@mcp_server.tool()
|
|
94
|
+
@meta_api_tool
|
|
95
|
+
async def create_adset(
|
|
96
|
+
account_id: str = None,
|
|
97
|
+
campaign_id: str = None,
|
|
98
|
+
name: str = None,
|
|
99
|
+
status: str = "PAUSED",
|
|
100
|
+
daily_budget = None,
|
|
101
|
+
lifetime_budget = None,
|
|
102
|
+
targeting: Dict[str, Any] = None,
|
|
103
|
+
optimization_goal: str = None,
|
|
104
|
+
billing_event: str = None,
|
|
105
|
+
bid_amount = None,
|
|
106
|
+
bid_strategy: str = None,
|
|
107
|
+
start_time: str = None,
|
|
108
|
+
end_time: str = None,
|
|
109
|
+
access_token: str = None
|
|
110
|
+
) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Create a new ad set in a Meta Ads account.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
116
|
+
campaign_id: Meta Ads campaign ID this ad set belongs to
|
|
117
|
+
name: Ad set name
|
|
118
|
+
status: Initial ad set status (default: PAUSED)
|
|
119
|
+
daily_budget: Daily budget in account currency (in cents) as a string
|
|
120
|
+
lifetime_budget: Lifetime budget in account currency (in cents) as a string
|
|
121
|
+
targeting: Targeting specifications including age, location, interests, etc.
|
|
122
|
+
Use targeting_automation.advantage_audience=1 for automatic audience finding
|
|
123
|
+
optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'REACH', 'CONVERSIONS')
|
|
124
|
+
billing_event: How you're charged (e.g., 'IMPRESSIONS', 'LINK_CLICKS')
|
|
125
|
+
bid_amount: Bid amount in account currency (in cents)
|
|
126
|
+
bid_strategy: Bid strategy (e.g., 'LOWEST_COST', 'LOWEST_COST_WITH_BID_CAP')
|
|
127
|
+
start_time: Start time in ISO 8601 format (e.g., '2023-12-01T12:00:00-0800')
|
|
128
|
+
end_time: End time in ISO 8601 format
|
|
129
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
130
|
+
"""
|
|
131
|
+
# Check required parameters
|
|
132
|
+
if not account_id:
|
|
133
|
+
return json.dumps({"error": "No account ID provided"}, indent=2)
|
|
134
|
+
|
|
135
|
+
if not campaign_id:
|
|
136
|
+
return json.dumps({"error": "No campaign ID provided"}, indent=2)
|
|
137
|
+
|
|
138
|
+
if not name:
|
|
139
|
+
return json.dumps({"error": "No ad set name provided"}, indent=2)
|
|
140
|
+
|
|
141
|
+
if not optimization_goal:
|
|
142
|
+
return json.dumps({"error": "No optimization goal provided"}, indent=2)
|
|
143
|
+
|
|
144
|
+
if not billing_event:
|
|
145
|
+
return json.dumps({"error": "No billing event provided"}, indent=2)
|
|
146
|
+
|
|
147
|
+
# Basic targeting is required if not provided
|
|
148
|
+
if not targeting:
|
|
149
|
+
targeting = {
|
|
150
|
+
"age_min": 18,
|
|
151
|
+
"age_max": 65,
|
|
152
|
+
"geo_locations": {"countries": ["US"]},
|
|
153
|
+
"targeting_automation": {"advantage_audience": 1}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
endpoint = f"{account_id}/adsets"
|
|
157
|
+
|
|
158
|
+
params = {
|
|
159
|
+
"name": name,
|
|
160
|
+
"campaign_id": campaign_id,
|
|
161
|
+
"status": status,
|
|
162
|
+
"optimization_goal": optimization_goal,
|
|
163
|
+
"billing_event": billing_event,
|
|
164
|
+
"targeting": json.dumps(targeting) # Properly format as JSON string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Convert budget values to strings if they aren't already
|
|
168
|
+
if daily_budget is not None:
|
|
169
|
+
params["daily_budget"] = str(daily_budget)
|
|
170
|
+
|
|
171
|
+
if lifetime_budget is not None:
|
|
172
|
+
params["lifetime_budget"] = str(lifetime_budget)
|
|
173
|
+
|
|
174
|
+
# Add other parameters if provided
|
|
175
|
+
if bid_amount is not None:
|
|
176
|
+
params["bid_amount"] = str(bid_amount)
|
|
177
|
+
|
|
178
|
+
if bid_strategy:
|
|
179
|
+
params["bid_strategy"] = bid_strategy
|
|
180
|
+
|
|
181
|
+
if start_time:
|
|
182
|
+
params["start_time"] = start_time
|
|
183
|
+
|
|
184
|
+
if end_time:
|
|
185
|
+
params["end_time"] = end_time
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
data = await make_api_request(endpoint, access_token, params, method="POST")
|
|
189
|
+
return json.dumps(data, indent=2)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
error_msg = str(e)
|
|
192
|
+
return json.dumps({
|
|
193
|
+
"error": "Failed to create ad set",
|
|
194
|
+
"details": error_msg,
|
|
195
|
+
"params_sent": params
|
|
196
|
+
}, indent=2)
|
|
197
|
+
|
|
198
|
+
|
|
78
199
|
@mcp_server.tool()
|
|
79
200
|
@meta_api_tool
|
|
80
201
|
async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, Any]] = None, bid_strategy: str = None,
|
|
81
|
-
bid_amount: int = None, status: str = None, targeting: Dict[str, Any] = None,
|
|
202
|
+
bid_amount: int = None, status: str = None, targeting: Dict[str, Any] = None,
|
|
203
|
+
optimization_goal: str = None, access_token: str = None) -> str:
|
|
82
204
|
"""
|
|
83
205
|
Update an ad set with new settings including frequency caps.
|
|
84
206
|
|
|
@@ -91,6 +213,7 @@ async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, An
|
|
|
91
213
|
status: Update ad set status (ACTIVE, PAUSED, etc.)
|
|
92
214
|
targeting: Targeting specifications including targeting_automation
|
|
93
215
|
(e.g. {"targeting_automation":{"advantage_audience":1}})
|
|
216
|
+
optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'CONVERSIONS', 'APP_INSTALLS', etc.)
|
|
94
217
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
95
218
|
"""
|
|
96
219
|
if not adset_id:
|
|
@@ -110,6 +233,9 @@ async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, An
|
|
|
110
233
|
if status is not None:
|
|
111
234
|
changes['status'] = status
|
|
112
235
|
|
|
236
|
+
if optimization_goal is not None:
|
|
237
|
+
changes['optimization_goal'] = optimization_goal
|
|
238
|
+
|
|
113
239
|
if targeting is not None:
|
|
114
240
|
# Get current ad set details to preserve existing targeting settings
|
|
115
241
|
current_details_json = await get_adset_details(adset_id=adset_id, access_token=access_token)
|
|
@@ -163,7 +289,7 @@ async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, An
|
|
|
163
289
|
"current_details": current_details,
|
|
164
290
|
"proposed_changes": changes,
|
|
165
291
|
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
166
|
-
"note": "
|
|
292
|
+
"note": "Click the link to confirm and apply your ad set updates. Refresh the browser page if it doesn't load immediately."
|
|
167
293
|
}
|
|
168
294
|
|
|
169
295
|
return json.dumps(response, indent=2)
|
meta_ads_mcp/core/api.py
CHANGED
|
@@ -193,7 +193,7 @@ def meta_api_tool(func):
|
|
|
193
193
|
logger.debug(f"Current app_id: {app_id}")
|
|
194
194
|
logger.debug(f"META_APP_ID env var: {os.environ.get('META_APP_ID')}")
|
|
195
195
|
|
|
196
|
-
# If access_token is not in kwargs, try to get it from auth_manager
|
|
196
|
+
# If access_token is not in kwargs or not kwargs['access_token'], try to get it from auth_manager
|
|
197
197
|
if 'access_token' not in kwargs or not kwargs['access_token']:
|
|
198
198
|
try:
|
|
199
199
|
access_token = await get_current_access_token()
|
|
@@ -202,13 +202,36 @@ def meta_api_tool(func):
|
|
|
202
202
|
logger.debug("Using access token from auth_manager")
|
|
203
203
|
else:
|
|
204
204
|
logger.warning("No access token available from auth_manager")
|
|
205
|
+
# Add more details about why token might be missing
|
|
206
|
+
if (auth_manager.app_id == "YOUR_META_APP_ID" or not auth_manager.app_id) and not auth_manager.use_pipeboard:
|
|
207
|
+
logger.error("TOKEN VALIDATION FAILED: No valid app_id configured")
|
|
208
|
+
logger.error("Please set META_APP_ID environment variable or configure in your code")
|
|
209
|
+
else:
|
|
210
|
+
logger.error("Check logs above for detailed token validation failures")
|
|
205
211
|
except Exception as e:
|
|
206
212
|
logger.error(f"Error getting access token: {str(e)}")
|
|
213
|
+
# Add stack trace for better debugging
|
|
214
|
+
import traceback
|
|
215
|
+
logger.error(f"Stack trace: {traceback.format_exc()}")
|
|
207
216
|
|
|
208
217
|
# Final validation - if we still don't have a valid token, return authentication required
|
|
209
218
|
if 'access_token' not in kwargs or not kwargs['access_token']:
|
|
210
219
|
logger.warning("No access token available, authentication needed")
|
|
220
|
+
|
|
221
|
+
# Add more specific troubleshooting information
|
|
211
222
|
auth_url = auth_manager.get_auth_url()
|
|
223
|
+
app_id = auth_manager.app_id
|
|
224
|
+
|
|
225
|
+
logger.error("TOKEN VALIDATION SUMMARY:")
|
|
226
|
+
logger.error(f"- Current app_id: '{app_id}'")
|
|
227
|
+
logger.error(f"- Environment META_APP_ID: '{os.environ.get('META_APP_ID', 'Not set')}'")
|
|
228
|
+
logger.error(f"- Pipeboard API token configured: {'Yes' if os.environ.get('PIPEBOARD_API_TOKEN') else 'No'}")
|
|
229
|
+
|
|
230
|
+
# Check for common configuration issues
|
|
231
|
+
if app_id == "YOUR_META_APP_ID" or not app_id:
|
|
232
|
+
logger.error("ISSUE DETECTED: No valid Meta App ID configured")
|
|
233
|
+
logger.error("ACTION REQUIRED: Set META_APP_ID environment variable with a valid App ID")
|
|
234
|
+
|
|
212
235
|
return json.dumps({
|
|
213
236
|
"error": {
|
|
214
237
|
"message": "Authentication Required",
|
|
@@ -216,6 +239,11 @@ def meta_api_tool(func):
|
|
|
216
239
|
"description": "You need to authenticate with the Meta API before using this tool",
|
|
217
240
|
"action_required": "Please authenticate first",
|
|
218
241
|
"auth_url": auth_url,
|
|
242
|
+
"configuration_status": {
|
|
243
|
+
"app_id_configured": bool(app_id) and app_id != "YOUR_META_APP_ID",
|
|
244
|
+
"pipeboard_enabled": bool(os.environ.get('PIPEBOARD_API_TOKEN')),
|
|
245
|
+
},
|
|
246
|
+
"troubleshooting": "Check logs for TOKEN VALIDATION FAILED messages",
|
|
219
247
|
"markdown_link": f"[Click here to authenticate with Meta Ads API]({auth_url})"
|
|
220
248
|
}
|
|
221
249
|
}
|
meta_ads_mcp/core/auth.py
CHANGED
|
@@ -18,8 +18,11 @@ from .callback_server import (
|
|
|
18
18
|
update_confirmation
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
+
# Import the new Pipeboard authentication
|
|
22
|
+
from .pipeboard_auth import pipeboard_auth_manager
|
|
23
|
+
|
|
21
24
|
# Auth constants
|
|
22
|
-
AUTH_SCOPE = "ads_management,ads_read,business_management"
|
|
25
|
+
AUTH_SCOPE = "ads_management,ads_read,business_management,public_profile"
|
|
23
26
|
AUTH_REDIRECT_URI = "http://localhost:8888/callback"
|
|
24
27
|
AUTH_RESPONSE_TYPE = "token"
|
|
25
28
|
|
|
@@ -39,8 +42,8 @@ class MetaConfig:
|
|
|
39
42
|
if cls._instance is None:
|
|
40
43
|
logger.debug("Creating new MetaConfig instance")
|
|
41
44
|
cls._instance = super(MetaConfig, cls).__new__(cls)
|
|
42
|
-
cls._instance.app_id = os.environ.get("META_APP_ID", "")
|
|
43
|
-
logger.info(f"MetaConfig initialized with app_id from env: {cls._instance.app_id}")
|
|
45
|
+
cls._instance.app_id = os.environ.get("META_APP_ID", "779761636818489")
|
|
46
|
+
logger.info(f"MetaConfig initialized with app_id from env/default: {cls._instance.app_id}")
|
|
44
47
|
return cls._instance
|
|
45
48
|
|
|
46
49
|
def set_app_id(self, app_id):
|
|
@@ -123,7 +126,10 @@ class AuthManager:
|
|
|
123
126
|
self.app_id = app_id
|
|
124
127
|
self.redirect_uri = redirect_uri
|
|
125
128
|
self.token_info = None
|
|
126
|
-
|
|
129
|
+
# Check for Pipeboard token first
|
|
130
|
+
self.use_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
|
|
131
|
+
if not self.use_pipeboard:
|
|
132
|
+
self._load_cached_token()
|
|
127
133
|
|
|
128
134
|
def _get_token_cache_path(self) -> pathlib.Path:
|
|
129
135
|
"""Get the platform-specific path for token cache file"""
|
|
@@ -154,14 +160,14 @@ class AuthManager:
|
|
|
154
160
|
|
|
155
161
|
# Check if token is expired
|
|
156
162
|
if self.token_info.is_expired():
|
|
157
|
-
|
|
163
|
+
logger.info("Cached token is expired")
|
|
158
164
|
self.token_info = None
|
|
159
165
|
return False
|
|
160
166
|
|
|
161
|
-
|
|
167
|
+
logger.info(f"Loaded cached token (expires in {(self.token_info.created_at + self.token_info.expires_in) - int(time.time())} seconds)")
|
|
162
168
|
return True
|
|
163
169
|
except Exception as e:
|
|
164
|
-
|
|
170
|
+
logger.error(f"Error loading cached token: {e}")
|
|
165
171
|
return False
|
|
166
172
|
|
|
167
173
|
def _save_token_to_cache(self) -> None:
|
|
@@ -174,9 +180,9 @@ class AuthManager:
|
|
|
174
180
|
try:
|
|
175
181
|
with open(cache_path, "w") as f:
|
|
176
182
|
json.dump(self.token_info.serialize(), f)
|
|
177
|
-
|
|
183
|
+
logger.info(f"Token cached at: {cache_path}")
|
|
178
184
|
except Exception as e:
|
|
179
|
-
|
|
185
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
180
186
|
|
|
181
187
|
def get_auth_url(self) -> str:
|
|
182
188
|
"""Generate the Facebook OAuth URL for desktop app flow"""
|
|
@@ -198,6 +204,12 @@ class AuthManager:
|
|
|
198
204
|
Returns:
|
|
199
205
|
Access token if successful, None otherwise
|
|
200
206
|
"""
|
|
207
|
+
# If Pipeboard auth is available, use that instead
|
|
208
|
+
if self.use_pipeboard:
|
|
209
|
+
logger.info("Using Pipeboard authentication")
|
|
210
|
+
return pipeboard_auth_manager.get_access_token(force_refresh=force_refresh)
|
|
211
|
+
|
|
212
|
+
# Otherwise, use the original OAuth flow
|
|
201
213
|
# Check if we already have a valid token
|
|
202
214
|
if not force_refresh and self.token_info and not self.token_info.is_expired():
|
|
203
215
|
return self.token_info.access_token
|
|
@@ -212,7 +224,7 @@ class AuthManager:
|
|
|
212
224
|
auth_url = self.get_auth_url()
|
|
213
225
|
|
|
214
226
|
# Open browser with auth URL
|
|
215
|
-
|
|
227
|
+
logger.info(f"Opening browser with URL: {auth_url}")
|
|
216
228
|
webbrowser.open(auth_url)
|
|
217
229
|
|
|
218
230
|
# We don't wait for the token here anymore
|
|
@@ -227,6 +239,10 @@ class AuthManager:
|
|
|
227
239
|
Returns:
|
|
228
240
|
Access token if available, None otherwise
|
|
229
241
|
"""
|
|
242
|
+
# If using Pipeboard, always delegate to the Pipeboard auth manager
|
|
243
|
+
if self.use_pipeboard:
|
|
244
|
+
return pipeboard_auth_manager.get_access_token()
|
|
245
|
+
|
|
230
246
|
if not self.token_info or self.token_info.is_expired():
|
|
231
247
|
return None
|
|
232
248
|
|
|
@@ -234,8 +250,13 @@ class AuthManager:
|
|
|
234
250
|
|
|
235
251
|
def invalidate_token(self) -> None:
|
|
236
252
|
"""Invalidate the current token, usually because it has expired or is invalid"""
|
|
253
|
+
# If using Pipeboard, delegate to the Pipeboard auth manager
|
|
254
|
+
if self.use_pipeboard:
|
|
255
|
+
pipeboard_auth_manager.invalidate_token()
|
|
256
|
+
return
|
|
257
|
+
|
|
237
258
|
if self.token_info:
|
|
238
|
-
|
|
259
|
+
logger.info(f"Invalidating token: {self.token_info.access_token[:10]}...")
|
|
239
260
|
self.token_info = None
|
|
240
261
|
|
|
241
262
|
# Signal that authentication is needed
|
|
@@ -247,12 +268,12 @@ class AuthManager:
|
|
|
247
268
|
cache_path = self._get_token_cache_path()
|
|
248
269
|
if cache_path.exists():
|
|
249
270
|
os.remove(cache_path)
|
|
250
|
-
|
|
271
|
+
logger.info(f"Removed cached token file: {cache_path}")
|
|
251
272
|
except Exception as e:
|
|
252
|
-
|
|
273
|
+
logger.error(f"Error removing cached token file: {e}")
|
|
253
274
|
|
|
254
275
|
def clear_token(self) -> None:
|
|
255
|
-
"""
|
|
276
|
+
"""Alias for invalidate_token for consistency with other APIs"""
|
|
256
277
|
self.invalidate_token()
|
|
257
278
|
|
|
258
279
|
|
|
@@ -385,18 +406,55 @@ async def get_current_access_token() -> Optional[str]:
|
|
|
385
406
|
app_id = meta_config.get_app_id()
|
|
386
407
|
logger.debug(f"Current app_id: {app_id}")
|
|
387
408
|
|
|
409
|
+
# Check if using Pipeboard authentication
|
|
410
|
+
using_pipeboard = auth_manager.use_pipeboard
|
|
411
|
+
|
|
412
|
+
# Check if app_id is valid - but only if not using Pipeboard authentication
|
|
413
|
+
if not app_id and not using_pipeboard:
|
|
414
|
+
logger.error("TOKEN VALIDATION FAILED: No valid app_id configured")
|
|
415
|
+
logger.error("Please set META_APP_ID environment variable or configure via meta_config.set_app_id()")
|
|
416
|
+
return None
|
|
417
|
+
|
|
388
418
|
# Attempt to get access token
|
|
389
419
|
try:
|
|
390
420
|
token = auth_manager.get_access_token()
|
|
391
421
|
|
|
392
422
|
if token:
|
|
393
|
-
|
|
423
|
+
# Add basic token validation - check if it looks like a valid token
|
|
424
|
+
if len(token) < 20: # Most Meta tokens are much longer
|
|
425
|
+
logger.error(f"TOKEN VALIDATION FAILED: Token appears malformed (length: {len(token)})")
|
|
426
|
+
auth_manager.invalidate_token()
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
logger.debug(f"Access token found in auth_manager (starts with: {token[:10]}...)")
|
|
394
430
|
return token
|
|
395
431
|
else:
|
|
396
432
|
logger.warning("No valid access token available in auth_manager")
|
|
433
|
+
|
|
434
|
+
# Check why token might be missing
|
|
435
|
+
if hasattr(auth_manager, 'token_info') and auth_manager.token_info:
|
|
436
|
+
if auth_manager.token_info.is_expired():
|
|
437
|
+
logger.error("TOKEN VALIDATION FAILED: Token is expired")
|
|
438
|
+
# Add expiration details
|
|
439
|
+
if hasattr(auth_manager.token_info, 'expires_in') and auth_manager.token_info.expires_in:
|
|
440
|
+
expiry_time = auth_manager.token_info.created_at + auth_manager.token_info.expires_in
|
|
441
|
+
current_time = int(time.time())
|
|
442
|
+
expired_seconds_ago = current_time - expiry_time
|
|
443
|
+
logger.error(f"Token expired {expired_seconds_ago} seconds ago")
|
|
444
|
+
elif not auth_manager.token_info.access_token:
|
|
445
|
+
logger.error("TOKEN VALIDATION FAILED: Token object exists but access_token is empty")
|
|
446
|
+
else:
|
|
447
|
+
logger.error("TOKEN VALIDATION FAILED: Token exists but was rejected for unknown reason")
|
|
448
|
+
else:
|
|
449
|
+
logger.error("TOKEN VALIDATION FAILED: No token information available")
|
|
450
|
+
|
|
451
|
+
# Suggest next steps for troubleshooting
|
|
452
|
+
logger.error("To fix: Try re-authenticating or check if your token has been revoked")
|
|
397
453
|
return None
|
|
398
454
|
except Exception as e:
|
|
399
455
|
logger.error(f"Error getting access token: {str(e)}")
|
|
456
|
+
import traceback
|
|
457
|
+
logger.error(f"Token validation stacktrace: {traceback.format_exc()}")
|
|
400
458
|
return None
|
|
401
459
|
|
|
402
460
|
|
|
@@ -443,4 +501,14 @@ def login():
|
|
|
443
501
|
|
|
444
502
|
# Initialize auth manager with a placeholder - will be updated at runtime
|
|
445
503
|
META_APP_ID = os.environ.get("META_APP_ID", "YOUR_META_APP_ID")
|
|
504
|
+
|
|
505
|
+
# Only show warnings about missing META_APP_ID/META_APP_SECRET when not using Pipeboard
|
|
506
|
+
if not os.environ.get("PIPEBOARD_API_TOKEN"):
|
|
507
|
+
# Log warnings about missing environment variables
|
|
508
|
+
if META_APP_ID == "YOUR_META_APP_ID":
|
|
509
|
+
logger.warning("META_APP_ID environment variable is not set. Authentication will not work properly.")
|
|
510
|
+
|
|
511
|
+
if not os.environ.get("META_APP_SECRET"):
|
|
512
|
+
logger.warning("META_APP_SECRET environment variable is not set. Long-lived token exchange will not work.")
|
|
513
|
+
|
|
446
514
|
auth_manager = AuthManager(META_APP_ID)
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import asyncio
|
|
5
|
+
import os
|
|
5
6
|
from .api import meta_api_tool
|
|
6
7
|
from .auth import start_callback_server, auth_manager, get_current_access_token
|
|
7
8
|
from .server import mcp_server
|
|
8
9
|
from .utils import logger, META_APP_SECRET
|
|
10
|
+
from .pipeboard_auth import pipeboard_auth_manager
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
@mcp_server.tool()
|
|
@@ -13,60 +15,112 @@ async def get_login_link(access_token: str = None) -> str:
|
|
|
13
15
|
"""
|
|
14
16
|
Get a clickable login link for Meta Ads authentication.
|
|
15
17
|
|
|
18
|
+
NOTE: This method should only be used if you're using your own Facebook app.
|
|
19
|
+
If using Pipeboard authentication (recommended), set the PIPEBOARD_API_TOKEN
|
|
20
|
+
environment variable instead (token obtainable via https://pipeboard.co).
|
|
21
|
+
|
|
16
22
|
Args:
|
|
17
23
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
18
24
|
|
|
19
25
|
Returns:
|
|
20
26
|
A clickable resource link for Meta authentication
|
|
21
27
|
"""
|
|
22
|
-
# Check if we
|
|
23
|
-
|
|
24
|
-
token_status = "No token" if not cached_token else "Valid token"
|
|
28
|
+
# Check if we're using pipeboard authentication
|
|
29
|
+
using_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
if using_pipeboard:
|
|
32
|
+
# Handle Pipeboard authentication
|
|
33
|
+
# Check if we have a cached token
|
|
34
|
+
cached_token = pipeboard_auth_manager.get_access_token()
|
|
35
|
+
token_status = "No token" if not cached_token else "Valid token"
|
|
36
|
+
|
|
37
|
+
# If we already have a valid token and none was provided, just return success
|
|
38
|
+
if cached_token and not access_token:
|
|
39
|
+
logger.info("get_login_link called with existing valid Pipeboard token")
|
|
40
|
+
return json.dumps({
|
|
41
|
+
"message": "Already authenticated with Pipeboard",
|
|
42
|
+
"token_status": token_status,
|
|
43
|
+
"token_preview": cached_token[:10] + "..." if cached_token else None,
|
|
44
|
+
"authentication_method": "pipeboard"
|
|
45
|
+
}, indent=2)
|
|
46
|
+
|
|
47
|
+
# Initiate the auth flow via Pipeboard
|
|
48
|
+
try:
|
|
49
|
+
auth_data = pipeboard_auth_manager.initiate_auth_flow()
|
|
50
|
+
login_url = auth_data.get("loginUrl")
|
|
51
|
+
|
|
52
|
+
# Return a special format that helps the LLM format the response properly
|
|
53
|
+
response = {
|
|
54
|
+
"login_url": login_url,
|
|
55
|
+
"token_status": token_status,
|
|
56
|
+
"markdown_link": f"[Click here to authenticate with Meta Ads via Pipeboard]({login_url})",
|
|
57
|
+
"message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
|
|
58
|
+
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
59
|
+
"authentication_method": "pipeboard",
|
|
60
|
+
"token_duration": "Approximately 60 days",
|
|
61
|
+
"note": "After authenticating, the token will be automatically saved."
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return json.dumps(response, indent=2)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"Error initiating Pipeboard auth flow: {e}")
|
|
67
|
+
return json.dumps({
|
|
68
|
+
"error": f"Failed to initiate Pipeboard authentication: {str(e)}",
|
|
69
|
+
"message": "Please check your PIPEBOARD_API_TOKEN environment variable.",
|
|
70
|
+
"authentication_method": "pipeboard"
|
|
71
|
+
}, indent=2)
|
|
72
|
+
else:
|
|
73
|
+
# Original Meta authentication flow
|
|
74
|
+
# Check if we have a cached token
|
|
75
|
+
cached_token = auth_manager.get_access_token()
|
|
76
|
+
token_status = "No token" if not cached_token else "Valid token"
|
|
77
|
+
|
|
78
|
+
# If we already have a valid token and none was provided, just return success
|
|
79
|
+
if cached_token and not access_token:
|
|
80
|
+
logger.info("get_login_link called with existing valid token")
|
|
81
|
+
return json.dumps({
|
|
82
|
+
"message": "Already authenticated",
|
|
83
|
+
"token_status": token_status,
|
|
84
|
+
"token_preview": cached_token[:10] + "...",
|
|
85
|
+
"created_at": auth_manager.token_info.created_at if hasattr(auth_manager, "token_info") else None,
|
|
86
|
+
"expires_in": auth_manager.token_info.expires_in if hasattr(auth_manager, "token_info") else None,
|
|
87
|
+
"authentication_method": "meta_oauth"
|
|
88
|
+
}, indent=2)
|
|
89
|
+
|
|
90
|
+
# IMPORTANT: Start the callback server first by calling our helper function
|
|
91
|
+
# This ensures the server is ready before we provide the URL to the user
|
|
92
|
+
logger.info("Starting callback server for authentication")
|
|
93
|
+
port = start_callback_server()
|
|
94
|
+
logger.info(f"Callback server started on port {port}")
|
|
95
|
+
|
|
96
|
+
# Generate direct login URL
|
|
97
|
+
auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
|
|
98
|
+
logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
|
|
99
|
+
login_url = auth_manager.get_auth_url()
|
|
100
|
+
logger.info(f"Generated login URL: {login_url}")
|
|
101
|
+
|
|
102
|
+
# Check if we can exchange for long-lived tokens
|
|
103
|
+
token_exchange_supported = bool(META_APP_SECRET)
|
|
104
|
+
token_duration = "60 days" if token_exchange_supported else "1-2 hours"
|
|
105
|
+
|
|
106
|
+
# Return a special format that helps the LLM format the response properly
|
|
107
|
+
response = {
|
|
108
|
+
"login_url": login_url,
|
|
31
109
|
"token_status": token_status,
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# Check if we can exchange for long-lived tokens
|
|
50
|
-
token_exchange_supported = bool(META_APP_SECRET)
|
|
51
|
-
token_duration = "60 days" if token_exchange_supported else "1-2 hours"
|
|
52
|
-
|
|
53
|
-
# Return a special format that helps the LLM format the response properly
|
|
54
|
-
response = {
|
|
55
|
-
"login_url": login_url,
|
|
56
|
-
"token_status": token_status,
|
|
57
|
-
"server_status": f"Callback server running on port {port}",
|
|
58
|
-
"markdown_link": f"[Click here to authenticate with Meta Ads]({login_url})",
|
|
59
|
-
"message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
|
|
60
|
-
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
61
|
-
"token_exchange": "enabled" if token_exchange_supported else "disabled",
|
|
62
|
-
"token_duration": token_duration,
|
|
63
|
-
"token_exchange_message": f"Your authentication token will be valid for approximately {token_duration}." +
|
|
64
|
-
(" Long-lived token exchange is enabled." if token_exchange_supported else
|
|
65
|
-
" To enable long-lived tokens (60 days), set the META_APP_SECRET environment variable."),
|
|
66
|
-
"note": "After authenticating, the token will be automatically saved."
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
# Wait a moment to ensure the server is fully started
|
|
70
|
-
await asyncio.sleep(1)
|
|
71
|
-
|
|
72
|
-
return json.dumps(response, indent=2)
|
|
110
|
+
"server_status": f"Callback server running on port {port}",
|
|
111
|
+
"markdown_link": f"[Click here to authenticate with Meta Ads]({login_url})",
|
|
112
|
+
"message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
|
|
113
|
+
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
114
|
+
"token_exchange": "enabled" if token_exchange_supported else "disabled",
|
|
115
|
+
"token_duration": token_duration,
|
|
116
|
+
"authentication_method": "meta_oauth",
|
|
117
|
+
"token_exchange_message": f"Your authentication token will be valid for approximately {token_duration}." +
|
|
118
|
+
(" Long-lived token exchange is enabled." if token_exchange_supported else
|
|
119
|
+
" To enable long-lived tokens (60 days), set the META_APP_SECRET environment variable."),
|
|
120
|
+
"note": "After authenticating, the token will be automatically saved."
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Wait a moment to ensure the server is fully started
|
|
124
|
+
await asyncio.sleep(1)
|
|
125
|
+
|
|
126
|
+
return json.dumps(response, indent=2)
|