meta-ads-mcp 0.9.2__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.
Files changed (74) hide show
  1. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/accounts.py +27 -26
  4. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/openai_deep_research.py +2 -2
  5. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/pyproject.toml +1 -1
  6. meta_ads_mcp-0.9.3/tests/e2e_account_info_search_issue.py +320 -0
  7. meta_ads_mcp-0.9.3/tests/test_account_info_access_fix.py +317 -0
  8. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_dsa_beneficiary.py +12 -2
  9. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_dsa_integration.py +12 -2
  10. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/.github/workflows/publish.yml +0 -0
  11. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/.github/workflows/test.yml +0 -0
  12. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/.gitignore +0 -0
  13. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/CUSTOM_META_APP.md +0 -0
  14. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/Dockerfile +0 -0
  15. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/LICENSE +0 -0
  16. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/LOCAL_INSTALLATION.md +0 -0
  17. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/META_API_NOTES.md +0 -0
  18. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/README.md +0 -0
  19. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/RELEASE.md +0 -0
  20. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/STREAMABLE_HTTP_SETUP.md +0 -0
  21. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/examples/README.md +0 -0
  22. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/examples/example_http_client.py +0 -0
  23. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/future_improvements.md +0 -0
  24. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/images/meta-ads-example.png +0 -0
  25. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_auth.sh +0 -0
  26. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/__main__.py +0 -0
  27. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/__init__.py +0 -0
  28. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/ads.py +0 -0
  29. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/ads_library.py +0 -0
  30. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/adsets.py +0 -0
  31. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/api.py +0 -0
  32. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/auth.py +0 -0
  33. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/authentication.py +0 -0
  34. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/budget_schedules.py +0 -0
  35. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/callback_server.py +0 -0
  36. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/campaigns.py +0 -0
  37. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/duplication.py +0 -0
  38. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  39. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/insights.py +0 -0
  40. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  41. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/reports.py +0 -0
  42. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/resources.py +0 -0
  43. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/server.py +0 -0
  44. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/targeting.py +0 -0
  45. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/utils.py +0 -0
  46. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/requirements.txt +0 -0
  47. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/setup.py +0 -0
  48. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/smithery.yaml +0 -0
  49. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/README.md +0 -0
  50. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/README_REGRESSION_TESTS.md +0 -0
  51. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/__init__.py +0 -0
  52. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/conftest.py +0 -0
  53. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_account_search.py +0 -0
  54. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_budget_update.py +0 -0
  55. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_budget_update_e2e.py +0 -0
  56. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_duplication.py +0 -0
  57. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_duplication_regression.py +0 -0
  58. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_dynamic_creatives.py +0 -0
  59. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_account_pages.py +0 -0
  60. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_ad_creatives_fix.py +0 -0
  61. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_ad_image_quality_improvements.py +0 -0
  62. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_ad_image_regression.py +0 -0
  63. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_http_transport.py +0 -0
  64. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_insights_actions_and_values.py +0 -0
  65. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_integration_openai_mcp.py +0 -0
  66. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_mobile_app_adset_creation.py +0 -0
  67. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_mobile_app_adset_issue.py +0 -0
  68. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_openai.py +0 -0
  69. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_openai_mcp_deep_research.py +0 -0
  70. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_page_discovery.py +0 -0
  71. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_page_discovery_integration.py +0 -0
  72. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_targeting.py +0 -0
  73. {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_targeting_search_e2e.py +0 -0
  74. {meta_ads_mcp-0.9.2 → 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.2
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
@@ -7,7 +7,7 @@ with the Claude LLM.
7
7
 
8
8
  from meta_ads_mcp.core.server import main
9
9
 
10
- __version__ = "0.9.2"
10
+ __version__ = "0.9.3"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -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
- # First, check if the account is accessible to the user
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
@@ -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 = 25) -> List[Dict[str, Any]]:
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=25)
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.2"
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"
@@ -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()
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unit tests for get_account_info accessibility fix.
4
+
5
+ This module tests the fix for the issue where get_account_info couldn't access
6
+ accounts that were visible in get_ad_accounts but not in the limited direct
7
+ accessibility list.
8
+
9
+ The fix changes the logic to try fetching account info directly first,
10
+ rather than pre-checking against a limited accessibility list.
11
+ """
12
+
13
+ import pytest
14
+ import json
15
+ from unittest.mock import AsyncMock, patch
16
+
17
+ from meta_ads_mcp.core.accounts import get_account_info
18
+
19
+
20
+ class TestAccountInfoAccessFix:
21
+ """Test cases for the get_account_info accessibility fix"""
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_account_info_direct_access_success(self):
25
+ """Test that get_account_info works when direct API call succeeds"""
26
+
27
+ # Mock the direct account info API response
28
+ mock_account_response = {
29
+ "id": "act_414174661097171",
30
+ "name": "Venture Hunting & Outdoors",
31
+ "account_id": "414174661097171",
32
+ "account_status": 1,
33
+ "amount_spent": "5818510",
34
+ "balance": "97677",
35
+ "currency": "AUD",
36
+ "timezone_name": "Australia/Brisbane",
37
+ "business_country_code": "AU"
38
+ }
39
+
40
+ with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
41
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
42
+ mock_auth.return_value = "test_access_token"
43
+ mock_api.return_value = mock_account_response
44
+
45
+ result = await get_account_info(account_id="414174661097171")
46
+
47
+ # Handle both string and dict return formats
48
+ if isinstance(result, str):
49
+ result_data = json.loads(result)
50
+ else:
51
+ result_data = result
52
+
53
+ # Verify the account info was returned successfully
54
+ assert "error" not in result_data
55
+ assert result_data["id"] == "act_414174661097171"
56
+ assert result_data["name"] == "Venture Hunting & Outdoors"
57
+ assert result_data["account_id"] == "414174661097171"
58
+ assert result_data["currency"] == "AUD"
59
+ assert result_data["timezone_name"] == "Australia/Brisbane"
60
+
61
+ # Verify DSA compliance detection was added
62
+ assert "dsa_required" in result_data
63
+ assert result_data["dsa_required"] is False # AU is not European
64
+ assert "dsa_compliance_note" in result_data
65
+
66
+ # Verify the API was called with correct parameters
67
+ mock_api.assert_called_once_with(
68
+ "act_414174661097171",
69
+ "test_access_token",
70
+ {
71
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
72
+ }
73
+ )
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_account_info_permission_error_with_helpful_message(self):
77
+ """Test that permission errors provide helpful error messages with accessible accounts"""
78
+
79
+ # Mock the permission error response from direct API call
80
+ mock_permission_error = {
81
+ "error": {
82
+ "message": "Insufficient privileges to access the object",
83
+ "type": "OAuthException",
84
+ "code": 200
85
+ }
86
+ }
87
+
88
+ # Mock accessible accounts response for helpful error message
89
+ mock_accessible_accounts = {
90
+ "data": [
91
+ {"id": "act_123456", "name": "Accessible Account 1"},
92
+ {"id": "act_789012", "name": "Accessible Account 2"},
93
+ {"id": "act_345678", "name": "Accessible Account 3"}
94
+ ]
95
+ }
96
+
97
+ with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
98
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
99
+ mock_auth.return_value = "test_access_token"
100
+
101
+ # First call returns permission error, second call returns accessible accounts
102
+ mock_api.side_effect = [mock_permission_error, mock_accessible_accounts]
103
+
104
+ result = await get_account_info(account_id="414174661097171")
105
+
106
+ # Handle both string and dict return formats
107
+ if isinstance(result, str):
108
+ result_data = json.loads(result)
109
+ else:
110
+ result_data = result
111
+
112
+ # Verify helpful error message
113
+ assert "error" in result_data
114
+ assert "not accessible to your user account" in result_data["error"]["message"]
115
+ assert "accessible_accounts" in result_data["error"]
116
+ assert "suggestion" in result_data["error"]
117
+ assert result_data["error"]["total_accessible_accounts"] == 3
118
+
119
+ # Verify accessible accounts list
120
+ accessible_accounts = result_data["error"]["accessible_accounts"]
121
+ assert len(accessible_accounts) == 3
122
+ assert accessible_accounts[0]["id"] == "act_123456"
123
+ assert accessible_accounts[0]["name"] == "Accessible Account 1"
124
+
125
+ # Verify API calls were made
126
+ assert mock_api.call_count == 2
127
+
128
+ # First call: direct account access attempt
129
+ mock_api.assert_any_call(
130
+ "act_414174661097171",
131
+ "test_access_token",
132
+ {
133
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
134
+ }
135
+ )
136
+
137
+ # Second call: get accessible accounts for error message
138
+ mock_api.assert_any_call(
139
+ "me/adaccounts",
140
+ "test_access_token",
141
+ {
142
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
143
+ "limit": 50
144
+ }
145
+ )
146
+
147
+ @pytest.mark.asyncio
148
+ async def test_account_info_non_permission_error_passthrough(self):
149
+ """Test that non-permission errors are passed through unchanged"""
150
+
151
+ # Mock a non-permission error (e.g., invalid account ID)
152
+ mock_error_response = {
153
+ "error": {
154
+ "message": "Invalid account ID format",
155
+ "type": "GraphAPIException",
156
+ "code": 100
157
+ }
158
+ }
159
+
160
+ with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
161
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
162
+ mock_auth.return_value = "test_access_token"
163
+ mock_api.return_value = mock_error_response
164
+
165
+ result = await get_account_info(account_id="invalid_id")
166
+
167
+ # Handle both string and dict return formats
168
+ if isinstance(result, str):
169
+ result_data = json.loads(result)
170
+ else:
171
+ result_data = result
172
+
173
+ # Verify the original error is returned unchanged
174
+ assert result_data == mock_error_response
175
+
176
+ # Verify only one API call was made (no attempt to get accessible accounts)
177
+ mock_api.assert_called_once()
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_account_info_missing_account_id_error(self):
181
+ """Test that missing account_id parameter returns appropriate error"""
182
+
183
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
184
+ mock_auth.return_value = "test_access_token"
185
+
186
+ result = await get_account_info(account_id=None)
187
+
188
+ # Handle both string and dict return formats
189
+ if isinstance(result, str):
190
+ result_data = json.loads(result)
191
+ else:
192
+ result_data = result
193
+
194
+ # Verify error message
195
+ assert "error" in result_data
196
+ assert "Account ID is required" in result_data["error"]["message"]
197
+ assert "Please specify an account_id parameter" in result_data["error"]["details"]
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_account_info_act_prefix_handling(self):
201
+ """Test that account_id prefix handling works correctly"""
202
+
203
+ mock_account_response = {
204
+ "id": "act_123456789",
205
+ "name": "Test Account",
206
+ "account_id": "123456789"
207
+ }
208
+
209
+ with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
210
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
211
+ mock_auth.return_value = "test_access_token"
212
+ mock_api.return_value = mock_account_response
213
+
214
+ # Test with account ID without act_ prefix
215
+ result = await get_account_info(account_id="123456789")
216
+
217
+ # Verify the API was called with the act_ prefix added
218
+ mock_api.assert_called_once_with(
219
+ "act_123456789", # Should have act_ prefix added
220
+ "test_access_token",
221
+ {
222
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
223
+ }
224
+ )
225
+
226
+ @pytest.mark.asyncio
227
+ async def test_account_info_european_dsa_detection(self):
228
+ """Test that DSA requirements are properly detected for European accounts"""
229
+
230
+ # Mock account response for German business
231
+ mock_account_response = {
232
+ "id": "act_999888777",
233
+ "name": "German Test Account",
234
+ "account_id": "999888777",
235
+ "business_country_code": "DE" # Germany - should trigger DSA
236
+ }
237
+
238
+ with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
239
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
240
+ mock_auth.return_value = "test_access_token"
241
+ mock_api.return_value = mock_account_response
242
+
243
+ result = await get_account_info(account_id="999888777")
244
+
245
+ # Handle both string and dict return formats
246
+ if isinstance(result, str):
247
+ result_data = json.loads(result)
248
+ else:
249
+ result_data = result
250
+
251
+ # Verify DSA requirements were properly detected
252
+ assert "dsa_required" in result_data
253
+ assert result_data["dsa_required"] is True # DE is European
254
+ assert "dsa_compliance_note" in result_data
255
+ assert "European DSA" in result_data["dsa_compliance_note"]
256
+
257
+
258
+ class TestAccountInfoAccessRegression:
259
+ """Regression tests to ensure the fix doesn't break existing functionality"""
260
+
261
+ @pytest.mark.asyncio
262
+ async def test_regression_basic_account_info_still_works(self):
263
+ """Regression test: ensure basic account info functionality still works"""
264
+
265
+ mock_account_response = {
266
+ "id": "act_123456789",
267
+ "name": "Basic Test Account",
268
+ "account_id": "123456789",
269
+ "account_status": 1,
270
+ "currency": "USD",
271
+ "business_country_code": "US"
272
+ }
273
+
274
+ with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
275
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
276
+ mock_auth.return_value = "test_access_token"
277
+ mock_api.return_value = mock_account_response
278
+
279
+ result = await get_account_info(account_id="act_123456789")
280
+
281
+ # Handle both string and dict return formats
282
+ if isinstance(result, str):
283
+ result_data = json.loads(result)
284
+ else:
285
+ result_data = result
286
+
287
+ # Verify basic functionality works
288
+ assert "error" not in result_data
289
+ assert result_data["id"] == "act_123456789"
290
+ assert result_data["name"] == "Basic Test Account"
291
+
292
+ def test_account_info_fix_comparison(self):
293
+ """
294
+ Documentation test: explains what the fix changed
295
+
296
+ BEFORE: get_account_info checked accessibility first against limited list (50 accounts)
297
+ AFTER: get_account_info tries direct API call first, only shows error if API fails
298
+
299
+ This allows accounts visible through business manager (like 414174661097171)
300
+ to work properly even if they're not in the limited direct accessibility list.
301
+ """
302
+
303
+ # This is a documentation test - no actual code execution
304
+ old_behavior = "Pre-check accessibility against limited 50 account list"
305
+ new_behavior = "Try direct API call first, handle permission errors gracefully"
306
+
307
+ assert old_behavior != new_behavior
308
+
309
+ # The key insight: get_ad_accounts shows 107 accounts through business manager,
310
+ # but "me/adaccounts" only shows 50 directly accessible accounts
311
+ total_visible_accounts = 107
312
+ directly_accessible_accounts = 50
313
+
314
+ assert total_visible_accounts > directly_accessible_accounts
315
+
316
+ # Account 414174661097171 was in the 107 but not in the 50
317
+ # The fix allows get_account_info to work for such accounts
@@ -122,7 +122,16 @@ class TestDSABeneficiaryDetection:
122
122
  async def test_account_info_inaccessible_account_error(self):
123
123
  """Test that get_account_info provides helpful error for inaccessible accounts"""
124
124
 
125
- # Mock accessible accounts response
125
+ # Mock permission error for direct account access (first API call)
126
+ mock_permission_error = {
127
+ "error": {
128
+ "message": "Insufficient access privileges",
129
+ "type": "OAuthException",
130
+ "code": 200
131
+ }
132
+ }
133
+
134
+ # Mock accessible accounts response (second API call)
126
135
  mock_accessible_accounts = {
127
136
  "data": [
128
137
  {"id": "act_123", "name": "Test Account 1"},
@@ -133,7 +142,8 @@ class TestDSABeneficiaryDetection:
133
142
  with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
134
143
  with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
135
144
  mock_auth.return_value = "test_access_token"
136
- mock_api.return_value = mock_accessible_accounts
145
+ # First call returns permission error, second call returns accessible accounts
146
+ mock_api.side_effect = [mock_permission_error, mock_accessible_accounts]
137
147
 
138
148
  result = await get_account_info(account_id="act_inaccessible")
139
149
 
@@ -420,7 +420,16 @@ class TestDSAIntegration:
420
420
  async def test_account_info_inaccessible_account_error(self):
421
421
  """Test that get_account_info provides helpful error for inaccessible accounts"""
422
422
 
423
- # Mock accessible accounts response
423
+ # Mock permission error for direct account access (first API call)
424
+ mock_permission_error = {
425
+ "error": {
426
+ "message": "Insufficient access privileges",
427
+ "type": "OAuthException",
428
+ "code": 200
429
+ }
430
+ }
431
+
432
+ # Mock accessible accounts response (second API call)
424
433
  mock_accessible_accounts = {
425
434
  "data": [
426
435
  {"id": "act_123", "name": "Test Account 1"},
@@ -431,7 +440,8 @@ class TestDSAIntegration:
431
440
  with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
432
441
  with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
433
442
  mock_auth.return_value = "test_access_token"
434
- mock_api.return_value = mock_accessible_accounts
443
+ # First call returns permission error, second call returns accessible accounts
444
+ mock_api.side_effect = [mock_permission_error, mock_accessible_accounts]
435
445
 
436
446
  result = await get_account_info(account_id="act_inaccessible")
437
447
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes