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.
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/PKG-INFO +1 -1
- meta_ads_mcp-0.7.9/RELEASE.md +166 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/accounts.py +40 -11
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads.py +16 -4
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/auth.py +38 -2
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/pipeboard_auth.py +43 -1
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/utils.py +18 -6
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/pyproject.toml +1 -1
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_dsa_beneficiary.py +69 -5
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_dsa_integration.py +88 -19
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_get_ad_creatives_fix.py +3 -3
- meta_ads_mcp-0.7.9/tests/test_get_ad_image_quality_improvements.py +391 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_get_ad_image_regression.py +74 -2
- meta_ads_mcp-0.7.7/RELEASE.md +0 -97
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/.gitignore +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/Dockerfile +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/LICENSE +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/README.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/examples/README.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/future_improvements.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/openai_deep_research.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/requirements.txt +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/setup.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/smithery.yaml +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/README.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_insights_actions_and_values.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.7.7 → meta_ads_mcp-0.7.9}/tests/test_targeting.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
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
|
|
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"
|
|
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
|
-
#
|
|
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
|
-
|
|
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 = "
|
|
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 = []
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|