meta-ads-mcp 0.7.7__tar.gz → 0.7.9__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 (66) hide show
  1. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/PKG-INFO +1 -1
  2. meta_ads_mcp-0.7.9/RELEASE.md +166 -0
  3. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__init__.py +1 -1
  4. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/accounts.py +40 -11
  5. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads.py +16 -4
  6. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/auth.py +38 -2
  7. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/pipeboard_auth.py +43 -1
  8. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/utils.py +18 -6
  9. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/pyproject.toml +1 -1
  10. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_dsa_beneficiary.py +69 -5
  11. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_dsa_integration.py +88 -19
  12. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_get_ad_creatives_fix.py +3 -3
  13. meta_ads_mcp-0.7.9/tests/test_get_ad_image_quality_improvements.py +391 -0
  14. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_get_ad_image_regression.py +74 -2
  15. meta_ads_mcp-0.7.7/RELEASE.md +0 -97
  16. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/.github/workflows/publish.yml +0 -0
  17. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/.github/workflows/test.yml +0 -0
  18. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/.gitignore +0 -0
  19. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/CUSTOM_META_APP.md +0 -0
  20. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/Dockerfile +0 -0
  21. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/LICENSE +0 -0
  22. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/LOCAL_INSTALLATION.md +0 -0
  23. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/META_API_NOTES.md +0 -0
  24. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/README.md +0 -0
  25. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/STREAMABLE_HTTP_SETUP.md +0 -0
  26. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/examples/README.md +0 -0
  27. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/examples/example_http_client.py +0 -0
  28. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/future_improvements.md +0 -0
  29. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/images/meta-ads-example.png +0 -0
  30. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_auth.sh +0 -0
  31. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__main__.py +0 -0
  32. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/__init__.py +0 -0
  33. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads_library.py +0 -0
  34. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/adsets.py +0 -0
  35. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/api.py +0 -0
  36. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/authentication.py +0 -0
  37. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/budget_schedules.py +0 -0
  38. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/callback_server.py +0 -0
  39. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/campaigns.py +0 -0
  40. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/duplication.py +0 -0
  41. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  42. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/insights.py +0 -0
  43. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/openai_deep_research.py +0 -0
  44. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/reports.py +0 -0
  45. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/resources.py +0 -0
  46. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/server.py +0 -0
  47. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/targeting.py +0 -0
  48. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/requirements.txt +0 -0
  49. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/setup.py +0 -0
  50. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/smithery.yaml +0 -0
  51. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/README.md +0 -0
  52. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/README_REGRESSION_TESTS.md +0 -0
  53. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/__init__.py +0 -0
  54. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/conftest.py +0 -0
  55. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_account_search.py +0 -0
  56. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_budget_update.py +0 -0
  57. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_budget_update_e2e.py +0 -0
  58. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_duplication.py +0 -0
  59. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_duplication_regression.py +0 -0
  60. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_http_transport.py +0 -0
  61. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_insights_actions_and_values.py +0 -0
  62. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_integration_openai_mcp.py +0 -0
  63. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_openai.py +0 -0
  64. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_openai_mcp_deep_research.py +0 -0
  65. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_targeting.py +0 -0
  66. {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_targeting_search_e2e.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.7.7
3
+ Version: 0.7.9
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
@@ -0,0 +1,166 @@
1
+ # Release Process
2
+
3
+ This repository uses GitHub Actions to automatically publish releases to PyPI. Here's the optimized release process:
4
+
5
+ ## 🚀 Quick Release (Recommended)
6
+
7
+ ### Prerequisites
8
+ - ✅ **Trusted Publishing Configured**: Repository uses PyPI trusted publishing with OIDC tokens
9
+ - ✅ **GitHub CLI installed**: `gh` command available for streamlined releases
10
+ - ✅ **Clean working directory**: No uncommitted changes
11
+
12
+ ### Optimal Release Process
13
+
14
+ 1. **Update version in both files** (use consistent versioning):
15
+
16
+ ```bash
17
+ # Update pyproject.toml
18
+ sed -i '' 's/version = "0.7.7"/version = "0.7.8"/' pyproject.toml
19
+
20
+ # Update __init__.py
21
+ sed -i '' 's/__version__ = "0.7.7"/__version__ = "0.7.8"/' meta_ads_mcp/__init__.py
22
+ ```
23
+
24
+ Or manually edit:
25
+ - `pyproject.toml`: `version = "0.7.8"`
26
+ - `meta_ads_mcp/__init__.py`: `__version__ = "0.7.8"`
27
+
28
+ 2. **Commit and push version changes**:
29
+ ```bash
30
+ git add pyproject.toml meta_ads_mcp/__init__.py
31
+ git commit -m "Bump version to 0.7.8"
32
+ git push origin main
33
+ ```
34
+
35
+ 3. **Create GitHub release** (triggers automatic PyPI publishing):
36
+ ```bash
37
+ # Use bash wrapper if gh has issues in Cursor
38
+ bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
39
+ ```
40
+
41
+ 4. **Verify release** (optional):
42
+ ```bash
43
+ # Check GitHub release
44
+ curl -s "https://api.github.com/repos/pipeboard-co/meta-ads-mcp/releases/latest" | grep -E '"tag_name"|"name"'
45
+
46
+ # Check PyPI availability (wait 2-3 minutes)
47
+ curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep -E '"version"|"0.7.8"'
48
+ ```
49
+
50
+ ## 📋 Detailed Release Process
51
+
52
+ ### Version Management Best Practices
53
+
54
+ - **Semantic Versioning**: Follow `MAJOR.MINOR.PATCH` (e.g., 0.7.8)
55
+ - **Synchronized Files**: Always update BOTH version files
56
+ - **Commit Convention**: Use `"Bump version to X.Y.Z"` format
57
+ - **Release Tag**: GitHub release tag matches version (no "v" prefix)
58
+
59
+ ### Pre-Release Checklist
60
+
61
+ ```bash
62
+ # 1. Ensure clean working directory
63
+ git status
64
+
65
+ # 2. Run tests locally (optional but recommended)
66
+ uv run python -m pytest tests/ -v
67
+
68
+ # 3. Check current version
69
+ grep -E "version =|__version__" pyproject.toml meta_ads_mcp/__init__.py
70
+ ```
71
+
72
+ ### Release Commands (One-liner)
73
+
74
+ ```bash
75
+ # Complete release in one sequence
76
+ VERSION="0.7.8" && \
77
+ sed -i '' "s/version = \"0.7.7\"/version = \"$VERSION\"/" pyproject.toml && \
78
+ sed -i '' "s/__version__ = \"0.7.7\"/__version__ = \"$VERSION\"/" meta_ads_mcp/__init__.py && \
79
+ git add pyproject.toml meta_ads_mcp/__init__.py && \
80
+ git commit -m "Bump version to $VERSION" && \
81
+ git push origin main && \
82
+ bash -c "gh release create $VERSION --title '$VERSION' --generate-notes"
83
+ ```
84
+
85
+ ## 🔄 Workflows
86
+
87
+ ### `publish.yml` (Automatic)
88
+ - **Trigger**: GitHub release creation
89
+ - **Purpose**: Build and publish to PyPI
90
+ - **Security**: OIDC tokens (no API keys)
91
+ - **Status**: ✅ Fully automated
92
+
93
+ ### `test.yml` (Validation)
94
+ - **Trigger**: Push to main/master
95
+ - **Purpose**: Package structure validation
96
+ - **Matrix**: Python 3.10, 3.11, 3.12
97
+ - **Note**: Build tests only, not pytest
98
+
99
+ ## 🛠️ Troubleshooting
100
+
101
+ ### Common Issues
102
+
103
+ 1. **gh command issues in Cursor**:
104
+ ```bash
105
+ # Use bash wrapper
106
+ bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
107
+ ```
108
+
109
+ 2. **Version mismatch**:
110
+ ```bash
111
+ # Verify both files have same version
112
+ grep -E "version =|__version__" pyproject.toml meta_ads_mcp/__init__.py
113
+ ```
114
+
115
+ 3. **PyPI not updated**:
116
+ ```bash
117
+ # Check if package is available (wait 2-3 minutes)
118
+ curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep '"version"'
119
+ ```
120
+
121
+ ### Manual Deployment (Fallback)
122
+
123
+ ```bash
124
+ # Install build tools
125
+ pip install build twine
126
+
127
+ # Build package
128
+ python -m build
129
+
130
+ # Upload to PyPI (requires API token)
131
+ python -m twine upload dist/*
132
+ ```
133
+
134
+ ## 📊 Release Verification
135
+
136
+ ### GitHub Release
137
+ - ✅ Release created with correct tag
138
+ - ✅ Auto-generated notes from commits
139
+ - ✅ Actions tab shows successful workflow
140
+
141
+ ### PyPI Package
142
+ - ✅ Package available for installation
143
+ - ✅ Correct version displayed
144
+ - ✅ All dependencies listed
145
+
146
+ ### Installation Test
147
+ ```bash
148
+ # Test new version installation
149
+ pip install meta-ads-mcp==0.7.8
150
+ # or
151
+ uvx meta-ads-mcp@0.7.8
152
+ ```
153
+
154
+ ## 🔒 Security Notes
155
+
156
+ - **Trusted Publishing**: Uses GitHub OIDC tokens (no API keys needed)
157
+ - **Isolated Builds**: All builds run in GitHub-hosted runners
158
+ - **Access Control**: Only maintainers can create releases
159
+ - **Audit Trail**: All releases tracked in GitHub Actions
160
+
161
+ ## 📈 Release Metrics
162
+
163
+ Track successful releases:
164
+ - **GitHub Releases**: https://github.com/pipeboard-co/meta-ads-mcp/releases
165
+ - **PyPI Package**: https://pypi.org/project/meta-ads-mcp/
166
+ - **Actions History**: https://github.com/pipeboard-co/meta-ads-mcp/actions
@@ -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.7.7"
10
+ __version__ = "0.7.9"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -36,29 +36,58 @@ async def get_account_info(access_token: str = None, account_id: str = None) ->
36
36
 
37
37
  Args:
38
38
  access_token: Meta API access token (optional - will use cached token if not provided)
39
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
39
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX) - REQUIRED
40
40
  """
41
- # If no account ID is specified, try to get the first one for the user
42
41
  if not account_id:
43
- accounts_json = await get_ad_accounts("me", json.dumps({"limit": 1}), access_token)
44
- accounts_data = json.loads(accounts_json)
45
-
46
- if "data" in accounts_data and accounts_data["data"]:
47
- account_id = accounts_data["data"][0]["id"]
48
- else:
49
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
42
+ return {
43
+ "error": {
44
+ "message": "Account ID is required",
45
+ "details": "Please specify an account_id parameter",
46
+ "example": "Use account_id='act_123456789' or account_id='123456789'"
47
+ }
48
+ }
50
49
 
51
50
  # Ensure account_id has the 'act_' prefix for API compatibility
52
51
  if not account_id.startswith("act_"):
53
52
  account_id = f"act_{account_id}"
54
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
+
55
80
  endpoint = f"{account_id}"
56
81
  params = {
57
- "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,funding_source_details,business_city,business_country_code,timezone_name,owner"
82
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
58
83
  }
59
84
 
60
85
  data = await make_api_request(endpoint, access_token, params)
61
86
 
87
+ # Check if the API request returned an error
88
+ if "error" in data:
89
+ return data
90
+
62
91
  # Add DSA requirement detection
63
92
  if "business_country_code" in data:
64
93
  european_countries = ["DE", "FR", "IT", "ES", "NL", "BE", "AT", "IE", "DK", "SE", "FI", "NO"]
@@ -69,4 +98,4 @@ async def get_account_info(access_token: str = None, account_id: str = None) ->
69
98
  data["dsa_required"] = False
70
99
  data["dsa_compliance_note"] = "This account is not subject to European DSA requirements"
71
100
 
72
- return json.dumps(data, indent=2)
101
+ return data
@@ -171,7 +171,7 @@ async def get_ad_creatives(access_token: str = None, ad_id: str = None) -> str:
171
171
 
172
172
  endpoint = f"{ad_id}/adcreatives"
173
173
  params = {
174
- "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec" # Added image_hash
174
+ "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,asset_feed_spec,image_urls_for_viewing"
175
175
  }
176
176
 
177
177
  data = await make_api_request(endpoint, access_token, params)
@@ -279,14 +279,26 @@ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
279
279
  if "data" in creative_data and creative_data["data"]:
280
280
  creative = creative_data["data"][0]
281
281
 
282
- # Try image_urls_for_viewing first (usually higher quality)
282
+ # Prioritize higher quality image URLs in this order:
283
+ # 1. image_urls_for_viewing (usually highest quality)
284
+ # 2. image_url (direct field)
285
+ # 3. object_story_spec.link_data.picture (usually full size)
286
+ # 4. thumbnail_url (last resort - often profile thumbnail)
287
+
283
288
  if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
284
289
  image_url = creative["image_urls_for_viewing"][0]
285
290
  print(f"Using image_urls_for_viewing: {image_url}")
286
- # Fall back to thumbnail_url
291
+ elif "image_url" in creative and creative["image_url"]:
292
+ image_url = creative["image_url"]
293
+ print(f"Using image_url: {image_url}")
294
+ elif "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
295
+ link_data = creative["object_story_spec"]["link_data"]
296
+ if "picture" in link_data and link_data["picture"]:
297
+ image_url = link_data["picture"]
298
+ print(f"Using object_story_spec.link_data.picture: {image_url}")
287
299
  elif "thumbnail_url" in creative and creative["thumbnail_url"]:
288
300
  image_url = creative["thumbnail_url"]
289
- print(f"Using thumbnail_url: {image_url}")
301
+ print(f"Using thumbnail_url (fallback): {image_url}")
290
302
 
291
303
  if not image_url:
292
304
  return "Error: No image URLs found in creative"
@@ -25,7 +25,7 @@ from .pipeboard_auth import pipeboard_auth_manager
25
25
  # Auth constants
26
26
  # Scope includes pages_show_list and pages_read_engagement to fix issue #16
27
27
  # where get_account_pages failed for regular users due to missing page permissions
28
- AUTH_SCOPE = "ads_management,ads_read,business_management,public_profile,pages_show_list,pages_read_engagement"
28
+ AUTH_SCOPE = "business_management,public_profile,pages_show_list,pages_read_engagement"
29
29
  AUTH_REDIRECT_URI = "http://localhost:8888/callback"
30
30
  AUTH_RESPONSE_TYPE = "token"
31
31
 
@@ -159,11 +159,41 @@ class AuthManager:
159
159
  try:
160
160
  with open(cache_path, "r") as f:
161
161
  data = json.load(f)
162
+
163
+ # Validate the cached data structure
164
+ required_fields = ["access_token", "created_at"]
165
+ if not all(field in data for field in required_fields):
166
+ logger.warning("Cached token data is missing required fields")
167
+ return False
168
+
169
+ # Check if the token looks valid (basic format check)
170
+ if not data.get("access_token") or len(data["access_token"]) < 20:
171
+ logger.warning("Cached token appears malformed")
172
+ return False
173
+
162
174
  self.token_info = TokenInfo.deserialize(data)
163
175
 
164
176
  # Check if token is expired
165
177
  if self.token_info.is_expired():
166
- logger.info("Cached token is expired")
178
+ logger.info("Cached token is expired, removing cache file")
179
+ # Remove the expired cache file
180
+ try:
181
+ cache_path.unlink()
182
+ logger.info(f"Removed expired token cache: {cache_path}")
183
+ except Exception as e:
184
+ logger.warning(f"Could not remove expired cache file: {e}")
185
+ self.token_info = None
186
+ return False
187
+
188
+ # Additional validation: check if token is too old (more than 60 days)
189
+ current_time = int(time.time())
190
+ if self.token_info.created_at and (current_time - self.token_info.created_at) > (60 * 24 * 3600):
191
+ logger.warning("Cached token is too old (more than 60 days), removing cache file")
192
+ try:
193
+ cache_path.unlink()
194
+ logger.info(f"Removed old token cache: {cache_path}")
195
+ except Exception as e:
196
+ logger.warning(f"Could not remove old cache file: {e}")
167
197
  self.token_info = None
168
198
  return False
169
199
 
@@ -171,6 +201,12 @@ class AuthManager:
171
201
  return True
172
202
  except Exception as e:
173
203
  logger.error(f"Error loading cached token: {e}")
204
+ # If there's any error reading the cache, try to remove the corrupted file
205
+ try:
206
+ cache_path.unlink()
207
+ logger.info(f"Removed corrupted token cache: {cache_path}")
208
+ except Exception as cleanup_error:
209
+ logger.warning(f"Could not remove corrupted cache file: {cleanup_error}")
174
210
  return False
175
211
 
176
212
  def _save_token_to_cache(self) -> None:
@@ -151,6 +151,18 @@ class PipeboardAuthManager:
151
151
  with open(cache_path, "r") as f:
152
152
  logger.debug(f"Reading token cache from {cache_path}")
153
153
  data = json.load(f)
154
+
155
+ # Validate the cached data structure
156
+ required_fields = ["access_token"]
157
+ if not all(field in data for field in required_fields):
158
+ logger.warning("Cached token data is missing required fields")
159
+ return False
160
+
161
+ # Check if the token looks valid (basic format check)
162
+ if not data.get("access_token") or len(data["access_token"]) < 20:
163
+ logger.warning("Cached token appears malformed")
164
+ return False
165
+
154
166
  self.token_info = TokenInfo.deserialize(data)
155
167
 
156
168
  # Log token details (partial token for security)
@@ -159,7 +171,25 @@ class PipeboardAuthManager:
159
171
 
160
172
  # Check if token is expired
161
173
  if self.token_info.is_expired():
162
- logger.info("Cached token is expired")
174
+ logger.info("Cached token is expired, removing cache file")
175
+ # Remove the expired cache file
176
+ try:
177
+ cache_path.unlink()
178
+ logger.info(f"Removed expired token cache: {cache_path}")
179
+ except Exception as e:
180
+ logger.warning(f"Could not remove expired cache file: {e}")
181
+ self.token_info = None
182
+ return False
183
+
184
+ # Additional validation: check if token is too old (more than 60 days)
185
+ current_time = int(time.time())
186
+ if self.token_info.created_at and (current_time - self.token_info.created_at) > (60 * 24 * 3600):
187
+ logger.warning("Cached token is too old (more than 60 days), removing cache file")
188
+ try:
189
+ cache_path.unlink()
190
+ logger.info(f"Removed old token cache: {cache_path}")
191
+ except Exception as e:
192
+ logger.warning(f"Could not remove old cache file: {e}")
163
193
  self.token_info = None
164
194
  return False
165
195
 
@@ -174,9 +204,21 @@ class PipeboardAuthManager:
174
204
  logger.debug(f"Raw cache file content (first 100 chars): {raw_content[:100]}")
175
205
  except Exception as e2:
176
206
  logger.error(f"Could not read raw cache file: {e2}")
207
+ # If there's any error reading the cache, try to remove the corrupted file
208
+ try:
209
+ cache_path.unlink()
210
+ logger.info(f"Removed corrupted token cache: {cache_path}")
211
+ except Exception as cleanup_error:
212
+ logger.warning(f"Could not remove corrupted cache file: {cleanup_error}")
177
213
  return False
178
214
  except Exception as e:
179
215
  logger.error(f"Error loading cached token: {e}")
216
+ # If there's any error reading the cache, try to remove the corrupted file
217
+ try:
218
+ cache_path.unlink()
219
+ logger.info(f"Removed corrupted token cache: {cache_path}")
220
+ except Exception as cleanup_error:
221
+ logger.warning(f"Could not remove corrupted cache file: {cleanup_error}")
180
222
  return False
181
223
 
182
224
  def _save_token_to_cache(self) -> None:
@@ -78,23 +78,31 @@ ad_creative_images = {}
78
78
  def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
79
79
  """
80
80
  Extract image URLs from a creative object for direct viewing.
81
+ Prioritizes higher quality images over thumbnails.
81
82
 
82
83
  Args:
83
84
  creative: Meta Ads creative object
84
85
 
85
86
  Returns:
86
- List of image URLs found in the creative
87
+ List of image URLs found in the creative, prioritized by quality
87
88
  """
88
89
  image_urls = []
89
90
 
91
+ # Prioritize higher quality image URLs in this order:
92
+ # 1. image_urls_for_viewing (usually highest quality)
93
+ # 2. image_url (direct field)
94
+ # 3. object_story_spec.link_data.picture (usually full size)
95
+ # 4. asset_feed_spec images (multiple high-quality images)
96
+ # 5. thumbnail_url (last resort - often profile thumbnail)
97
+
98
+ # Check for image_urls_for_viewing (highest priority)
99
+ if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
100
+ image_urls.extend(creative["image_urls_for_viewing"])
101
+
90
102
  # Check for direct image_url field
91
103
  if "image_url" in creative and creative["image_url"]:
92
104
  image_urls.append(creative["image_url"])
93
105
 
94
- # Check for thumbnail_url field
95
- if "thumbnail_url" in creative and creative["thumbnail_url"]:
96
- image_urls.append(creative["thumbnail_url"])
97
-
98
106
  # Check object_story_spec for image URLs
99
107
  if "object_story_spec" in creative:
100
108
  story_spec = creative["object_story_spec"]
@@ -103,7 +111,7 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
103
111
  if "link_data" in story_spec:
104
112
  link_data = story_spec["link_data"]
105
113
 
106
- # Check for picture field
114
+ # Check for picture field (usually full size)
107
115
  if "picture" in link_data and link_data["picture"]:
108
116
  image_urls.append(link_data["picture"])
109
117
 
@@ -121,6 +129,10 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
121
129
  if "url" in image and image["url"]:
122
130
  image_urls.append(image["url"])
123
131
 
132
+ # Check for thumbnail_url field (lowest priority)
133
+ if "thumbnail_url" in creative and creative["thumbnail_url"]:
134
+ image_urls.append(creative["thumbnail_url"])
135
+
124
136
  # Remove duplicates while preserving order
125
137
  seen = set()
126
138
  unique_urls = []
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.7.7"
7
+ version = "0.7.9"
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"
@@ -19,10 +19,10 @@ class TestDSABeneficiaryDetection:
19
19
 
20
20
  @pytest.mark.asyncio
21
21
  async def test_dsa_requirement_detection_business_account(self):
22
- """Test detection of DSA requirements for business accounts"""
22
+ """Test DSA requirement detection for European business accounts"""
23
23
  mock_account_response = {
24
24
  "id": "act_701351919139047",
25
- "name": "Test Business Account",
25
+ "name": "Test European Business Account",
26
26
  "account_status": 1,
27
27
  "business_country_code": "DE", # Germany - DSA compliant
28
28
  "business_city": "Berlin",
@@ -35,7 +35,12 @@ class TestDSABeneficiaryDetection:
35
35
  mock_api.return_value = mock_account_response
36
36
 
37
37
  result = await get_account_info(account_id="act_701351919139047")
38
- result_data = json.loads(result)
38
+
39
+ # Handle new return format (dictionary instead of JSON string)
40
+ if isinstance(result, dict):
41
+ result_data = result
42
+ else:
43
+ result_data = json.loads(result)
39
44
 
40
45
  # Verify account info is retrieved
41
46
  assert result_data["id"] == "act_701351919139047"
@@ -62,7 +67,12 @@ class TestDSABeneficiaryDetection:
62
67
  mock_api.return_value = mock_account_response
63
68
 
64
69
  result = await get_account_info(account_id="act_123456789")
65
- result_data = json.loads(result)
70
+
71
+ # Handle new return format (dictionary instead of JSON string)
72
+ if isinstance(result, dict):
73
+ result_data = result
74
+ else:
75
+ result_data = json.loads(result)
66
76
 
67
77
  # Verify no DSA requirement for US accounts
68
78
  assert result_data["business_country_code"] == "US"
@@ -77,7 +87,7 @@ class TestDSABeneficiaryDetection:
77
87
 
78
88
  result = await get_account_info(account_id="act_invalid")
79
89
 
80
- # Handle response format - could be dict or JSON string
90
+ # Handle new return format (dictionary instead of JSON string)
81
91
  if isinstance(result, dict):
82
92
  result_data = result
83
93
  else:
@@ -85,6 +95,60 @@ class TestDSABeneficiaryDetection:
85
95
 
86
96
  # Verify error is properly handled
87
97
  assert "error" in result_data
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_account_info_requires_account_id(self):
101
+ """Test that get_account_info requires an account_id parameter"""
102
+
103
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
104
+ mock_auth.return_value = "test_access_token"
105
+
106
+ # Test without account_id parameter
107
+ result = await get_account_info()
108
+
109
+ # Handle new return format (dictionary instead of JSON string)
110
+ if isinstance(result, dict):
111
+ result_data = result
112
+ else:
113
+ result_data = json.loads(result)
114
+
115
+ # Verify error message for missing account_id
116
+ assert "error" in result_data
117
+ assert "Account ID is required" in result_data["error"]["message"]
118
+ assert "Please specify an account_id parameter" in result_data["error"]["details"]
119
+ assert "example" in result_data["error"]
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_account_info_inaccessible_account_error(self):
123
+ """Test that get_account_info provides helpful error for inaccessible accounts"""
124
+
125
+ # Mock accessible accounts response
126
+ mock_accessible_accounts = {
127
+ "data": [
128
+ {"id": "act_123", "name": "Test Account 1"},
129
+ {"id": "act_456", "name": "Test Account 2"}
130
+ ]
131
+ }
132
+
133
+ with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
134
+ with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
135
+ mock_auth.return_value = "test_access_token"
136
+ mock_api.return_value = mock_accessible_accounts
137
+
138
+ result = await get_account_info(account_id="act_inaccessible")
139
+
140
+ # Handle new return format (dictionary instead of JSON string)
141
+ if isinstance(result, dict):
142
+ result_data = result
143
+ else:
144
+ result_data = json.loads(result)
145
+
146
+ # Verify helpful error message for inaccessible account
147
+ assert "error" in result_data
148
+ assert "not accessible to your user account" in result_data["error"]["message"]
149
+ assert "accessible_accounts" in result_data["error"]
150
+ assert "suggestion" in result_data["error"]
151
+ assert len(result_data["error"]["accessible_accounts"]) == 2
88
152
 
89
153
 
90
154
  class TestDSABeneficiaryParameter: