meta-ads-mcp 0.9.1__tar.gz → 0.9.3__tar.gz
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-0.9.1 → meta_ads_mcp-0.9.3}/PKG-INFO +2 -2
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/README.md +1 -1
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/accounts.py +29 -28
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/adsets.py +68 -1
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/openai_deep_research.py +2 -2
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/pyproject.toml +9 -1
- meta_ads_mcp-0.9.3/tests/e2e_account_info_search_issue.py +320 -0
- meta_ads_mcp-0.9.3/tests/test_account_info_access_fix.py +317 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_dsa_beneficiary.py +12 -2
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_dsa_integration.py +12 -2
- meta_ads_mcp-0.9.3/tests/test_mobile_app_adset_creation.py +501 -0
- meta_ads_mcp-0.9.3/tests/test_mobile_app_adset_issue.py +315 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/.gitignore +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/Dockerfile +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/LICENSE +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/RELEASE.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/examples/README.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/future_improvements.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/ads.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/requirements.txt +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/setup.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/smithery.yaml +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/README.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_dynamic_creatives.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_get_account_pages.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_get_ad_creatives_fix.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_get_ad_image_quality_improvements.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_get_ad_image_regression.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_insights_actions_and_values.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_page_discovery.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_page_discovery_integration.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_targeting_search_e2e.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.3}/tests/test_update_ad_creative_id.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.3
|
|
4
4
|
Summary: Model Context Protocol (MCP) plugin for interacting with Meta Ads API
|
|
5
5
|
Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
|
|
@@ -136,7 +136,7 @@ For local installation configuration, authentication options, and advanced techn
|
|
|
136
136
|
- Inputs:
|
|
137
137
|
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
138
138
|
- `user_id`: Meta user ID or "me" for the current user
|
|
139
|
-
- `limit`: Maximum number of accounts to return (default:
|
|
139
|
+
- `limit`: Maximum number of accounts to return (default: 200)
|
|
140
140
|
- Returns: List of accessible ad accounts with their details
|
|
141
141
|
|
|
142
142
|
2. `mcp_meta_ads_get_account_info`
|
|
@@ -111,7 +111,7 @@ For local installation configuration, authentication options, and advanced techn
|
|
|
111
111
|
- Inputs:
|
|
112
112
|
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
113
113
|
- `user_id`: Meta user ID or "me" for the current user
|
|
114
|
-
- `limit`: Maximum number of accounts to return (default:
|
|
114
|
+
- `limit`: Maximum number of accounts to return (default: 200)
|
|
115
115
|
- Returns: List of accessible ad accounts with their details
|
|
116
116
|
|
|
117
117
|
2. `mcp_meta_ads_get_account_info`
|
|
@@ -8,14 +8,14 @@ from .server import mcp_server
|
|
|
8
8
|
|
|
9
9
|
@mcp_server.tool()
|
|
10
10
|
@meta_api_tool
|
|
11
|
-
async def get_ad_accounts(access_token: str = None, user_id: str = "me", limit: int =
|
|
11
|
+
async def get_ad_accounts(access_token: str = None, user_id: str = "me", limit: int = 200) -> str:
|
|
12
12
|
"""
|
|
13
13
|
Get ad accounts accessible by a user.
|
|
14
14
|
|
|
15
15
|
Args:
|
|
16
16
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
17
17
|
user_id: Meta user ID or "me" for the current user
|
|
18
|
-
limit: Maximum number of accounts to return (default:
|
|
18
|
+
limit: Maximum number of accounts to return (default: 200)
|
|
19
19
|
"""
|
|
20
20
|
endpoint = f"{user_id}/adaccounts"
|
|
21
21
|
params = {
|
|
@@ -51,32 +51,7 @@ async def get_account_info(access_token: str = None, account_id: str = None) ->
|
|
|
51
51
|
if not account_id.startswith("act_"):
|
|
52
52
|
account_id = f"act_{account_id}"
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
endpoint = "me/adaccounts"
|
|
56
|
-
params = {
|
|
57
|
-
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
|
|
58
|
-
"limit": 50
|
|
59
|
-
}
|
|
60
|
-
accessible_accounts_data = await make_api_request(endpoint, access_token, params)
|
|
61
|
-
|
|
62
|
-
if "data" in accessible_accounts_data:
|
|
63
|
-
accessible_account_ids = [acc["id"] for acc in accessible_accounts_data["data"]]
|
|
64
|
-
if account_id not in accessible_account_ids:
|
|
65
|
-
# Provide a helpful error message with accessible accounts
|
|
66
|
-
accessible_accounts = [
|
|
67
|
-
{"id": acc["id"], "name": acc["name"]}
|
|
68
|
-
for acc in accessible_accounts_data["data"][:10] # Show first 10
|
|
69
|
-
]
|
|
70
|
-
return {
|
|
71
|
-
"error": {
|
|
72
|
-
"message": f"Account {account_id} is not accessible to your user account",
|
|
73
|
-
"details": "This account either doesn't exist or you don't have permission to access it",
|
|
74
|
-
"accessible_accounts": accessible_accounts,
|
|
75
|
-
"total_accessible_accounts": len(accessible_accounts_data["data"]),
|
|
76
|
-
"suggestion": "Try using one of the accessible account IDs listed above"
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
54
|
+
# Try to get the account info directly first
|
|
80
55
|
endpoint = f"{account_id}"
|
|
81
56
|
params = {
|
|
82
57
|
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
|
|
@@ -86,6 +61,32 @@ async def get_account_info(access_token: str = None, account_id: str = None) ->
|
|
|
86
61
|
|
|
87
62
|
# Check if the API request returned an error
|
|
88
63
|
if "error" in data:
|
|
64
|
+
# If access was denied, provide helpful error message with accessible accounts
|
|
65
|
+
if "access" in str(data.get("error", {})).lower() or "permission" in str(data.get("error", {})).lower():
|
|
66
|
+
# Get list of accessible accounts for helpful error message
|
|
67
|
+
accessible_endpoint = "me/adaccounts"
|
|
68
|
+
accessible_params = {
|
|
69
|
+
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
|
|
70
|
+
"limit": 50
|
|
71
|
+
}
|
|
72
|
+
accessible_accounts_data = await make_api_request(accessible_endpoint, access_token, accessible_params)
|
|
73
|
+
|
|
74
|
+
if "data" in accessible_accounts_data:
|
|
75
|
+
accessible_accounts = [
|
|
76
|
+
{"id": acc["id"], "name": acc["name"]}
|
|
77
|
+
for acc in accessible_accounts_data["data"][:10] # Show first 10
|
|
78
|
+
]
|
|
79
|
+
return {
|
|
80
|
+
"error": {
|
|
81
|
+
"message": f"Account {account_id} is not accessible to your user account",
|
|
82
|
+
"details": "This account either doesn't exist or you don't have permission to access it",
|
|
83
|
+
"accessible_accounts": accessible_accounts,
|
|
84
|
+
"total_accessible_accounts": len(accessible_accounts_data["data"]),
|
|
85
|
+
"suggestion": "Try using one of the accessible account IDs listed above"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Return the original error for non-permission related issues
|
|
89
90
|
return data
|
|
90
91
|
|
|
91
92
|
# Add DSA requirement detection
|
|
@@ -104,6 +104,8 @@ async def create_adset(
|
|
|
104
104
|
start_time: str = None,
|
|
105
105
|
end_time: str = None,
|
|
106
106
|
dsa_beneficiary: str = None,
|
|
107
|
+
promoted_object: Dict[str, Any] = None,
|
|
108
|
+
destination_type: str = None,
|
|
107
109
|
access_token: str = None
|
|
108
110
|
) -> str:
|
|
109
111
|
"""
|
|
@@ -118,13 +120,18 @@ async def create_adset(
|
|
|
118
120
|
lifetime_budget: Lifetime budget in account currency (in cents) as a string
|
|
119
121
|
targeting: Targeting specifications including age, location, interests, etc.
|
|
120
122
|
Use targeting_automation.advantage_audience=1 for automatic audience finding
|
|
121
|
-
optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'REACH', 'CONVERSIONS')
|
|
123
|
+
optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'REACH', 'CONVERSIONS', 'APP_INSTALLS')
|
|
122
124
|
billing_event: How you're charged (e.g., 'IMPRESSIONS', 'LINK_CLICKS')
|
|
123
125
|
bid_amount: Bid amount in account currency (in cents)
|
|
124
126
|
bid_strategy: Bid strategy (e.g., 'LOWEST_COST', 'LOWEST_COST_WITH_BID_CAP')
|
|
125
127
|
start_time: Start time in ISO 8601 format (e.g., '2023-12-01T12:00:00-0800')
|
|
126
128
|
end_time: End time in ISO 8601 format
|
|
127
129
|
dsa_beneficiary: DSA beneficiary (person/organization benefiting from ads) for European compliance
|
|
130
|
+
promoted_object: Mobile app configuration for APP_INSTALLS campaigns. Required fields: application_id, object_store_url.
|
|
131
|
+
Optional fields: custom_event_type, pixel_id, page_id.
|
|
132
|
+
Example: {"application_id": "123456789012345", "object_store_url": "https://apps.apple.com/app/id123456789"}
|
|
133
|
+
destination_type: Where users are directed after clicking the ad (e.g., 'APP_STORE', 'DEEPLINK', 'APP_INSTALL').
|
|
134
|
+
Required for mobile app campaigns.
|
|
128
135
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
129
136
|
"""
|
|
130
137
|
# Check required parameters
|
|
@@ -143,6 +150,59 @@ async def create_adset(
|
|
|
143
150
|
if not billing_event:
|
|
144
151
|
return json.dumps({"error": "No billing event provided"}, indent=2)
|
|
145
152
|
|
|
153
|
+
# Validate mobile app parameters for APP_INSTALLS campaigns
|
|
154
|
+
if optimization_goal == "APP_INSTALLS":
|
|
155
|
+
if not promoted_object:
|
|
156
|
+
return json.dumps({
|
|
157
|
+
"error": "promoted_object is required for APP_INSTALLS optimization goal",
|
|
158
|
+
"details": "Mobile app campaigns must specify which app is being promoted",
|
|
159
|
+
"required_fields": ["application_id", "object_store_url"]
|
|
160
|
+
}, indent=2)
|
|
161
|
+
|
|
162
|
+
# Validate promoted_object structure
|
|
163
|
+
if not isinstance(promoted_object, dict):
|
|
164
|
+
return json.dumps({
|
|
165
|
+
"error": "promoted_object must be a dictionary",
|
|
166
|
+
"example": {"application_id": "123456789012345", "object_store_url": "https://apps.apple.com/app/id123456789"}
|
|
167
|
+
}, indent=2)
|
|
168
|
+
|
|
169
|
+
# Validate required promoted_object fields
|
|
170
|
+
if "application_id" not in promoted_object:
|
|
171
|
+
return json.dumps({
|
|
172
|
+
"error": "promoted_object missing required field: application_id",
|
|
173
|
+
"details": "application_id is the Facebook app ID for your mobile app"
|
|
174
|
+
}, indent=2)
|
|
175
|
+
|
|
176
|
+
if "object_store_url" not in promoted_object:
|
|
177
|
+
return json.dumps({
|
|
178
|
+
"error": "promoted_object missing required field: object_store_url",
|
|
179
|
+
"details": "object_store_url should be the App Store or Google Play URL for your app"
|
|
180
|
+
}, indent=2)
|
|
181
|
+
|
|
182
|
+
# Validate store URL format
|
|
183
|
+
store_url = promoted_object["object_store_url"]
|
|
184
|
+
valid_store_patterns = [
|
|
185
|
+
"apps.apple.com", # iOS App Store
|
|
186
|
+
"play.google.com", # Google Play Store
|
|
187
|
+
"itunes.apple.com" # Alternative iOS format
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
if not any(pattern in store_url for pattern in valid_store_patterns):
|
|
191
|
+
return json.dumps({
|
|
192
|
+
"error": "Invalid object_store_url format",
|
|
193
|
+
"details": "URL must be from App Store (apps.apple.com) or Google Play (play.google.com)",
|
|
194
|
+
"provided_url": store_url
|
|
195
|
+
}, indent=2)
|
|
196
|
+
|
|
197
|
+
# Validate destination_type if provided
|
|
198
|
+
if destination_type:
|
|
199
|
+
valid_destination_types = ["APP_STORE", "DEEPLINK", "APP_INSTALL"]
|
|
200
|
+
if destination_type not in valid_destination_types:
|
|
201
|
+
return json.dumps({
|
|
202
|
+
"error": f"Invalid destination_type: {destination_type}",
|
|
203
|
+
"valid_values": valid_destination_types
|
|
204
|
+
}, indent=2)
|
|
205
|
+
|
|
146
206
|
# Basic targeting is required if not provided
|
|
147
207
|
if not targeting:
|
|
148
208
|
targeting = {
|
|
@@ -187,6 +247,13 @@ async def create_adset(
|
|
|
187
247
|
if dsa_beneficiary:
|
|
188
248
|
params["dsa_beneficiary"] = dsa_beneficiary
|
|
189
249
|
|
|
250
|
+
# Add mobile app parameters if provided
|
|
251
|
+
if promoted_object:
|
|
252
|
+
params["promoted_object"] = json.dumps(promoted_object)
|
|
253
|
+
|
|
254
|
+
if destination_type:
|
|
255
|
+
params["destination_type"] = destination_type
|
|
256
|
+
|
|
190
257
|
try:
|
|
191
258
|
data = await make_api_request(endpoint, access_token, params, method="POST")
|
|
192
259
|
return json.dumps(data, indent=2)
|
|
@@ -23,7 +23,7 @@ class MetaAdsDataManager:
|
|
|
23
23
|
self._cache = {}
|
|
24
24
|
logger.debug("MetaAdsDataManager initialized")
|
|
25
25
|
|
|
26
|
-
async def _get_ad_accounts(self, access_token: str, limit: int =
|
|
26
|
+
async def _get_ad_accounts(self, access_token: str, limit: int = 200) -> List[Dict[str, Any]]:
|
|
27
27
|
"""Get ad accounts data"""
|
|
28
28
|
try:
|
|
29
29
|
endpoint = "me/adaccounts"
|
|
@@ -141,7 +141,7 @@ class MetaAdsDataManager:
|
|
|
141
141
|
|
|
142
142
|
try:
|
|
143
143
|
# Search ad accounts
|
|
144
|
-
accounts = await self._get_ad_accounts(access_token, limit=
|
|
144
|
+
accounts = await self._get_ad_accounts(access_token, limit=200)
|
|
145
145
|
for account in accounts:
|
|
146
146
|
account_text = f"{account.get('name', '')} {account.get('id', '')} {account.get('account_status', '')} {account.get('business_city', '')} {account.get('business_country_code', '')}".lower()
|
|
147
147
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meta-ads-mcp"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.3"
|
|
8
8
|
description = "Model Context Protocol (MCP) plugin for interacting with Meta Ads API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -39,3 +39,11 @@ meta-ads-mcp = "meta_ads_mcp:entrypoint"
|
|
|
39
39
|
|
|
40
40
|
[tool.hatch.build.targets.wheel]
|
|
41
41
|
packages = ["meta_ads_mcp"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
markers = [
|
|
45
|
+
"e2e: marks tests as end-to-end (requires running MCP server) - excluded from default runs",
|
|
46
|
+
]
|
|
47
|
+
addopts = "-v --strict-markers -m 'not e2e'"
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
E2E Test for Account Info Search Issue
|
|
4
|
+
|
|
5
|
+
This test reproduces the issue reported by a user where get_account_info
|
|
6
|
+
cannot find account ID 414174661097171, while get_ad_accounts can see it.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
1. Start the server: uv run python -m meta_ads_mcp --transport streamable-http --port 8080
|
|
10
|
+
2. Run test: uv run python tests/e2e_account_info_search_issue.py
|
|
11
|
+
|
|
12
|
+
Or with pytest (manual only):
|
|
13
|
+
uv run python -m pytest tests/e2e_account_info_search_issue.py -v -m e2e
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
import requests
|
|
18
|
+
import json
|
|
19
|
+
from typing import Dict, Any
|
|
20
|
+
|
|
21
|
+
@pytest.mark.e2e
|
|
22
|
+
@pytest.mark.skip(reason="E2E test - run manually only")
|
|
23
|
+
class TestAccountInfoSearchIssue:
|
|
24
|
+
"""E2E test for account info search issue"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, base_url: str = "http://localhost:8080"):
|
|
27
|
+
self.base_url = base_url.rstrip('/')
|
|
28
|
+
self.endpoint = f"{self.base_url}/mcp/"
|
|
29
|
+
self.request_id = 1
|
|
30
|
+
self.target_account_id = "414174661097171"
|
|
31
|
+
|
|
32
|
+
def test_get_ad_accounts_can_see_target_account(self):
|
|
33
|
+
"""Verify get_ad_accounts can see account 414174661097171"""
|
|
34
|
+
print(f"\n🔍 Testing if get_ad_accounts can see account {self.target_account_id}")
|
|
35
|
+
|
|
36
|
+
params = {
|
|
37
|
+
"name": "get_ad_accounts",
|
|
38
|
+
"arguments": {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
result = self._make_request("tools/call", params)
|
|
42
|
+
|
|
43
|
+
if not result["success"]:
|
|
44
|
+
return {
|
|
45
|
+
"success": False,
|
|
46
|
+
"error": f"Request failed: {result.get('error', 'Unknown error')}",
|
|
47
|
+
"status_code": result["status_code"]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
response_data = result["json"]["result"]
|
|
52
|
+
content = response_data.get("content", [{}])[0].get("text", "")
|
|
53
|
+
parsed_content = json.loads(content)
|
|
54
|
+
|
|
55
|
+
# Check for errors first
|
|
56
|
+
error_info = self._check_for_errors(parsed_content)
|
|
57
|
+
if error_info["has_error"]:
|
|
58
|
+
return {
|
|
59
|
+
"success": False,
|
|
60
|
+
"error": f"get_ad_accounts returned error: {error_info['error_message']}",
|
|
61
|
+
"error_format": error_info["format"]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Extract accounts data
|
|
65
|
+
accounts = []
|
|
66
|
+
if "data" in parsed_content:
|
|
67
|
+
data = parsed_content["data"]
|
|
68
|
+
|
|
69
|
+
# Handle case where data is already parsed (list/dict)
|
|
70
|
+
if isinstance(data, list):
|
|
71
|
+
accounts = data
|
|
72
|
+
elif isinstance(data, dict) and "data" in data:
|
|
73
|
+
accounts = data["data"]
|
|
74
|
+
|
|
75
|
+
# Handle case where data is a JSON string that needs parsing
|
|
76
|
+
elif isinstance(data, str):
|
|
77
|
+
try:
|
|
78
|
+
parsed_data = json.loads(data)
|
|
79
|
+
if isinstance(parsed_data, list):
|
|
80
|
+
accounts = parsed_data
|
|
81
|
+
elif isinstance(parsed_data, dict) and "data" in parsed_data:
|
|
82
|
+
accounts = parsed_data["data"]
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
pass
|
|
85
|
+
elif isinstance(parsed_content, list):
|
|
86
|
+
accounts = parsed_content
|
|
87
|
+
|
|
88
|
+
# Search for target account
|
|
89
|
+
found_account = None
|
|
90
|
+
for account in accounts:
|
|
91
|
+
if isinstance(account, dict):
|
|
92
|
+
account_id = account.get("id", "").replace("act_", "")
|
|
93
|
+
if account_id == self.target_account_id:
|
|
94
|
+
found_account = account
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
result_data = {
|
|
98
|
+
"success": True,
|
|
99
|
+
"total_accounts": len(accounts),
|
|
100
|
+
"target_account_found": found_account is not None,
|
|
101
|
+
"found_account_details": found_account if found_account else None,
|
|
102
|
+
"all_account_ids": [
|
|
103
|
+
acc.get("id", "").replace("act_", "")
|
|
104
|
+
for acc in accounts
|
|
105
|
+
if isinstance(acc, dict) and acc.get("id")
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
print(f"✅ get_ad_accounts results:")
|
|
110
|
+
print(f" Total accounts found: {result_data['total_accounts']}")
|
|
111
|
+
print(f" Target account {self.target_account_id} found: {result_data['target_account_found']}")
|
|
112
|
+
if found_account:
|
|
113
|
+
print(f" Account details: {found_account}")
|
|
114
|
+
|
|
115
|
+
return result_data
|
|
116
|
+
|
|
117
|
+
except json.JSONDecodeError as e:
|
|
118
|
+
return {
|
|
119
|
+
"success": False,
|
|
120
|
+
"error": f"Could not parse get_ad_accounts response: {str(e)}",
|
|
121
|
+
"raw_content": content
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def test_get_account_info_cannot_find_target_account(self):
|
|
125
|
+
"""Verify get_account_info cannot find account 414174661097171"""
|
|
126
|
+
print(f"\n🔍 Testing if get_account_info can find account {self.target_account_id}")
|
|
127
|
+
|
|
128
|
+
params = {
|
|
129
|
+
"name": "get_account_info",
|
|
130
|
+
"arguments": {
|
|
131
|
+
"account_id": self.target_account_id
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
result = self._make_request("tools/call", params)
|
|
136
|
+
|
|
137
|
+
if not result["success"]:
|
|
138
|
+
return {
|
|
139
|
+
"success": False,
|
|
140
|
+
"error": f"Request failed: {result.get('error', 'Unknown error')}",
|
|
141
|
+
"status_code": result["status_code"]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
response_data = result["json"]["result"]
|
|
146
|
+
content = response_data.get("content", [{}])[0].get("text", "")
|
|
147
|
+
parsed_content = json.loads(content)
|
|
148
|
+
|
|
149
|
+
# Check for errors
|
|
150
|
+
error_info = self._check_for_errors(parsed_content)
|
|
151
|
+
|
|
152
|
+
result_data = {
|
|
153
|
+
"success": True,
|
|
154
|
+
"has_error": error_info["has_error"],
|
|
155
|
+
"error_message": error_info.get("error_message", ""),
|
|
156
|
+
"error_format": error_info.get("format", ""),
|
|
157
|
+
"raw_response": parsed_content
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
print(f"✅ get_account_info results:")
|
|
161
|
+
print(f" Has error: {result_data['has_error']}")
|
|
162
|
+
if result_data['has_error']:
|
|
163
|
+
print(f" Error message: {result_data['error_message']}")
|
|
164
|
+
print(f" Error format: {result_data['error_format']}")
|
|
165
|
+
else:
|
|
166
|
+
print(f" Unexpected success: {parsed_content}")
|
|
167
|
+
|
|
168
|
+
return result_data
|
|
169
|
+
|
|
170
|
+
except json.JSONDecodeError as e:
|
|
171
|
+
return {
|
|
172
|
+
"success": False,
|
|
173
|
+
"error": f"Could not parse get_account_info response: {str(e)}",
|
|
174
|
+
"raw_content": content
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def _check_for_errors(self, parsed_content: Dict[str, Any]) -> Dict[str, Any]:
|
|
178
|
+
"""Properly handle both wrapped and direct error formats"""
|
|
179
|
+
|
|
180
|
+
# Check for data wrapper format first
|
|
181
|
+
if "data" in parsed_content:
|
|
182
|
+
data = parsed_content["data"]
|
|
183
|
+
|
|
184
|
+
# Handle case where data is already parsed (dict/list)
|
|
185
|
+
if isinstance(data, dict) and 'error' in data:
|
|
186
|
+
return {
|
|
187
|
+
"has_error": True,
|
|
188
|
+
"error_message": data['error'],
|
|
189
|
+
"error_details": data.get('details', ''),
|
|
190
|
+
"format": "wrapped_dict"
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Handle case where data is a JSON string that needs parsing
|
|
194
|
+
if isinstance(data, str):
|
|
195
|
+
try:
|
|
196
|
+
error_data = json.loads(data)
|
|
197
|
+
if 'error' in error_data:
|
|
198
|
+
return {
|
|
199
|
+
"has_error": True,
|
|
200
|
+
"error_message": error_data['error'],
|
|
201
|
+
"error_details": error_data.get('details', ''),
|
|
202
|
+
"format": "wrapped_json"
|
|
203
|
+
}
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
# Data field exists but isn't valid JSON
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
# Check for direct error format
|
|
209
|
+
if 'error' in parsed_content:
|
|
210
|
+
return {
|
|
211
|
+
"has_error": True,
|
|
212
|
+
"error_message": parsed_content['error'],
|
|
213
|
+
"error_details": parsed_content.get('details', ''),
|
|
214
|
+
"format": "direct"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {"has_error": False}
|
|
218
|
+
|
|
219
|
+
def _make_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
220
|
+
"""Make a JSON-RPC request to the MCP server"""
|
|
221
|
+
|
|
222
|
+
headers = {
|
|
223
|
+
"Content-Type": "application/json",
|
|
224
|
+
"Accept": "application/json, text/event-stream",
|
|
225
|
+
"User-Agent": "E2E-Test-Client/1.0"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
payload = {
|
|
229
|
+
"jsonrpc": "2.0",
|
|
230
|
+
"method": method,
|
|
231
|
+
"id": self.request_id
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if params:
|
|
235
|
+
payload["params"] = params
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
response = requests.post(
|
|
239
|
+
self.endpoint,
|
|
240
|
+
headers=headers,
|
|
241
|
+
json=payload,
|
|
242
|
+
timeout=30
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
self.request_id += 1
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"status_code": response.status_code,
|
|
249
|
+
"json": response.json() if response.status_code == 200 else None,
|
|
250
|
+
"text": response.text,
|
|
251
|
+
"success": response.status_code == 200
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
except requests.exceptions.RequestException as e:
|
|
255
|
+
return {
|
|
256
|
+
"status_code": 0,
|
|
257
|
+
"json": None,
|
|
258
|
+
"text": str(e),
|
|
259
|
+
"success": False,
|
|
260
|
+
"error": str(e)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def run_validation():
|
|
264
|
+
"""Run the validation tests"""
|
|
265
|
+
print("🚀 Starting Account Info Search Issue Validation")
|
|
266
|
+
print(f"Target Account ID: 414174661097171")
|
|
267
|
+
print("="*60)
|
|
268
|
+
|
|
269
|
+
test_instance = TestAccountInfoSearchIssue()
|
|
270
|
+
|
|
271
|
+
# Test 1: Check if get_ad_accounts can see the account
|
|
272
|
+
accounts_result = test_instance.test_get_ad_accounts_can_see_target_account()
|
|
273
|
+
|
|
274
|
+
# Test 2: Check if get_account_info can find the account
|
|
275
|
+
account_info_result = test_instance.test_get_account_info_cannot_find_target_account()
|
|
276
|
+
|
|
277
|
+
print("\n" + "="*60)
|
|
278
|
+
print("📊 VALIDATION SUMMARY")
|
|
279
|
+
print("="*60)
|
|
280
|
+
|
|
281
|
+
if accounts_result["success"]:
|
|
282
|
+
print(f"✅ get_ad_accounts: Found {accounts_result['total_accounts']} total accounts")
|
|
283
|
+
if accounts_result["target_account_found"]:
|
|
284
|
+
print(f"✅ get_ad_accounts: Target account 414174661097171 IS visible")
|
|
285
|
+
else:
|
|
286
|
+
print(f"❌ get_ad_accounts: Target account 414174661097171 is NOT visible")
|
|
287
|
+
print(f" Available account IDs: {accounts_result.get('all_account_ids', [])}")
|
|
288
|
+
else:
|
|
289
|
+
print(f"❌ get_ad_accounts: Failed - {accounts_result['error']}")
|
|
290
|
+
|
|
291
|
+
if account_info_result["success"]:
|
|
292
|
+
if account_info_result["has_error"]:
|
|
293
|
+
print(f"❌ get_account_info: Cannot find account (Error: {account_info_result['error_message']})")
|
|
294
|
+
else:
|
|
295
|
+
print(f"✅ get_account_info: Successfully found account")
|
|
296
|
+
else:
|
|
297
|
+
print(f"❌ get_account_info: Test failed - {account_info_result['error']}")
|
|
298
|
+
|
|
299
|
+
# Determine if issue is confirmed
|
|
300
|
+
issue_confirmed = (
|
|
301
|
+
accounts_result.get("success", False) and
|
|
302
|
+
accounts_result.get("target_account_found", False) and
|
|
303
|
+
account_info_result.get("success", False) and
|
|
304
|
+
account_info_result.get("has_error", False)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
print("\n" + "="*60)
|
|
308
|
+
if issue_confirmed:
|
|
309
|
+
print("🐛 ISSUE CONFIRMED:")
|
|
310
|
+
print(" - get_ad_accounts CAN see the account")
|
|
311
|
+
print(" - get_account_info CANNOT find the account")
|
|
312
|
+
print(" - This validates the user's complaint")
|
|
313
|
+
else:
|
|
314
|
+
print("🤔 ISSUE NOT CONFIRMED:")
|
|
315
|
+
print(" - The behavior may be different than reported")
|
|
316
|
+
print(" - Check individual test results above")
|
|
317
|
+
print("="*60)
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
run_validation()
|