This is page 1 of 6. Use http://codebase.md/nictuku/meta-ads-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ ├── publish-mcp.yml
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .python-version
├── .uv.toml
├── CUSTOM_META_APP.md
├── Dockerfile
├── examples
│ ├── example_http_client.py
│ └── README.md
├── future_improvements.md
├── images
│ └── meta-ads-example.png
├── LICENSE
├── LOCAL_INSTALLATION.md
├── meta_ads_auth.sh
├── meta_ads_mcp
│ ├── __init__.py
│ ├── __main__.py
│ └── core
│ ├── __init__.py
│ ├── accounts.py
│ ├── ads_library.py
│ ├── ads.py
│ ├── adsets.py
│ ├── api.py
│ ├── auth.py
│ ├── authentication.py
│ ├── budget_schedules.py
│ ├── callback_server.py
│ ├── campaigns.py
│ ├── duplication.py
│ ├── http_auth_integration.py
│ ├── insights.py
│ ├── openai_deep_research.py
│ ├── pipeboard_auth.py
│ ├── reports.py
│ ├── resources.py
│ ├── server.py
│ ├── targeting.py
│ └── utils.py
├── META_API_NOTES.md
├── poetry.lock
├── pyproject.toml
├── README.md
├── RELEASE.md
├── requirements.txt
├── server.json
├── setup.py
├── smithery.yaml
├── STREAMABLE_HTTP_SETUP.md
└── tests
├── __init__.py
├── conftest.py
├── e2e_account_info_search_issue.py
├── README_REGRESSION_TESTS.md
├── README.md
├── test_account_info_access_fix.py
├── test_account_search.py
├── test_budget_update_e2e.py
├── test_budget_update.py
├── test_create_ad_creative_simple.py
├── test_create_simple_creative_e2e.py
├── test_dsa_beneficiary.py
├── test_dsa_integration.py
├── test_duplication_regression.py
├── test_duplication.py
├── test_dynamic_creatives.py
├── test_estimate_audience_size_e2e.py
├── test_estimate_audience_size.py
├── test_get_account_pages.py
├── test_get_ad_creatives_fix.py
├── test_get_ad_image_quality_improvements.py
├── test_get_ad_image_regression.py
├── test_http_transport.py
├── test_insights_actions_and_values_e2e.py
├── test_insights_pagination.py
├── test_integration_openai_mcp.py
├── test_is_dynamic_creative_adset.py
├── test_mobile_app_adset_creation.py
├── test_mobile_app_adset_issue.py
├── test_openai_mcp_deep_research.py
├── test_openai.py
├── test_page_discovery_integration.py
├── test_page_discovery.py
├── test_targeting_search_e2e.py
├── test_targeting.py
├── test_update_ad_creative_id.py
└── test_upload_ad_image.py
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.10
2 |
```
--------------------------------------------------------------------------------
/.uv.toml:
--------------------------------------------------------------------------------
```toml
1 | [uv]
2 | python-path = ["python3.11"]
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Python-generated files
2 | __pycache__/
3 | *.py[oc]
4 | build/
5 | dist/
6 | wheels/
7 | *.egg-info
8 |
9 | # Virtual environments
10 | .venv
11 | .cursor/rules/meta-ads-credentials.mdc
12 | .DS_Store
13 |
14 | # Development environment files
15 | .python-version
16 | poetry.lock
17 | uv.lock
18 | .uv.toml
19 | .cursor/
20 |
21 | # Generated content
22 | ad_creatives/
23 |
24 | # Debug and development files
25 | debug/
26 | *.pyc
27 | .pytest_cache/
28 |
29 | # Keep organized directories but exclude some debug content
30 | !debug/README.md
31 |
32 | internal/
33 | uv.lock
34 |
```
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Meta Ads MCP Examples
2 |
3 | This directory contains example scripts and usage demonstrations for the Meta Ads MCP server.
4 |
5 | ## Files
6 |
7 | ### `http_client.py`
8 | A complete example HTTP client that demonstrates how to interact with the Meta Ads MCP server using the HTTP transport.
9 |
10 | **Features:**
11 | - Shows how to authenticate with Pipeboard tokens or Meta access tokens
12 | - Demonstrates all basic MCP operations (initialize, list tools, call tools)
13 | - Includes error handling and response formatting
14 | - Ready-to-use client class for integration
15 |
16 | **Usage:**
17 | ```bash
18 | # Start the MCP server
19 | python -m meta_ads_mcp --transport streamable-http --port 8080
20 |
21 | # Run the example (in another terminal)
22 | cd examples
23 | python http_client.py
24 | ```
25 |
26 | **Authentication:**
27 | - Set `PIPEBOARD_API_TOKEN` environment variable for Pipeboard auth
28 | - Or pass `meta_access_token` parameter for direct Meta API auth
29 |
30 | ## Adding New Examples
31 |
32 | When adding new example files:
33 | 1. Include comprehensive docstrings
34 | 2. Add usage instructions in comments
35 | 3. Update this README with file descriptions
36 | 4. Follow the same authentication patterns as `http_client.py`
```
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Meta Ads MCP Tests
2 |
3 | This directory contains integration tests for the Meta Ads MCP HTTP transport functionality.
4 |
5 | ## Test Structure
6 |
7 | - `test_http_transport.py` - Comprehensive HTTP transport integration tests
8 | - `conftest.py` - Pytest configuration and shared fixtures
9 | - `__init__.py` - Python package marker
10 |
11 | ## Running Tests
12 |
13 | ### Prerequisites
14 |
15 | 1. **Start the MCP server:**
16 | ```bash
17 | python -m meta_ads_mcp --transport streamable-http --port 8080 --host localhost
18 | ```
19 |
20 | 2. **Install test dependencies:**
21 | ```bash
22 | pip install pytest requests
23 | ```
24 |
25 | ### Running with pytest (recommended)
26 |
27 | ```bash
28 | # Run all tests with verbose output
29 | python -m pytest tests/ -v
30 |
31 | # Run specific test file
32 | python -m pytest tests/test_http_transport.py -v
33 |
34 | # Run with custom server URL
35 | MCP_TEST_SERVER_URL=http://localhost:9000 python -m pytest tests/ -v
36 | ```
37 |
38 | ### Running directly
39 |
40 | ```bash
41 | # Run the main integration test
42 | python tests/test_http_transport.py
43 |
44 | # Or from project root
45 | python -m tests.test_http_transport
46 | ```
47 |
48 | ## What the Tests Validate
49 |
50 | ### ✅ HTTP Transport Layer
51 | - Server availability and responsiveness
52 | - JSON-RPC 2.0 protocol compliance
53 | - Proper HTTP status codes and headers
54 | - Request/response format validation
55 |
56 | ### ✅ MCP Protocol Compliance
57 | - `initialize` method - Server capability exchange
58 | - `tools/list` method - Tool discovery and enumeration
59 | - `tools/call` method - Tool execution with parameters
60 | - Error handling and edge cases
61 |
62 | ### ✅ Authentication Integration
63 | - **No Authentication** - Proper rejection of unauthenticated requests
64 | - **Pipeboard Token** - Primary authentication method (`X-PIPEBOARD-API-TOKEN`)
65 | - **Meta App ID** - Fallback authentication method (`X-META-APP-ID`)
66 | - **Multiple Auth Methods** - Priority handling (Pipeboard takes precedence)
67 |
68 | ### ✅ Tool Execution
69 | - All 26 Meta Ads tools accessible via HTTP
70 | - Authentication context properly passed to tools
71 | - Expected behavior with test tokens (authentication required responses)
72 |
73 | ## Test Scenarios
74 |
75 | The test suite runs multiple authentication scenarios:
76 |
77 | 1. **No Authentication**: Tests that tools properly require authentication
78 | 2. **Pipeboard Token**: Tests the primary authentication path
79 | 3. **Custom Meta App**: Tests the fallback authentication path
80 | 4. **Both Methods**: Tests authentication priority (Pipeboard preferred)
81 |
82 | ## Expected Results
83 |
84 | With **test tokens** (used in automated tests):
85 | - ✅ HTTP transport: All requests succeed (200 OK)
86 | - ✅ MCP protocol: All methods work correctly
87 | - ✅ Authentication: Headers processed and passed to tools
88 | - ✅ Tool responses: "Authentication Required" (expected with invalid tokens)
89 |
90 | With **real tokens** (production usage):
91 | - ✅ All of the above PLUS actual Meta Ads data returned
92 |
93 | ## Continuous Integration
94 |
95 | These tests are designed to be run in CI/CD pipelines:
96 |
97 | ```bash
98 | # Start server in background
99 | python -m meta_ads_mcp --transport streamable-http --port 8080 &
100 | SERVER_PID=$!
101 |
102 | # Wait for server startup
103 | sleep 3
104 |
105 | # Run tests
106 | python -m pytest tests/ -v --tb=short
107 |
108 | # Cleanup
109 | kill $SERVER_PID
110 | ```
111 |
112 | ## Troubleshooting
113 |
114 | **Server not running:**
115 | ```
116 | SKIPPED [1] tests/conftest.py:25: MCP server not running at http://localhost:8080
117 | ```
118 | → Start the server first: `python -m meta_ads_mcp --transport streamable-http`
119 |
120 | **Connection refused:**
121 | ```
122 | requests.exceptions.ConnectionError: ('Connection aborted.', ...)
123 | ```
124 | → Check that the server is running on the expected port
125 |
126 | **406 Not Acceptable:**
127 | ```
128 | ❌ Request failed: 406
129 | ```
130 | → Ensure proper Accept headers are being sent (handled automatically by test suite)
131 |
132 | ## Contributing
133 |
134 | When adding new tests:
135 |
136 | 1. **Follow naming convention**: `test_*.py` for pytest discovery
137 | 2. **Use fixtures**: Leverage existing fixtures in `conftest.py`
138 | 3. **Test both success and failure cases**
139 | 4. **Document expected behavior** with test tokens vs real tokens
140 | 5. **Keep tests isolated**: Each test should be independent
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Meta Ads MCP
2 |
3 | A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for interacting with Meta Ads. Analyze, manage and optimize Meta advertising campaigns through an AI interface. Use an LLM to retrieve performance data, visualize ad creatives, and provide strategic insights for your ads on Facebook, Instagram, and other Meta platforms.
4 |
5 | > **DISCLAIMER:** This is an unofficial third-party tool and is not associated with, endorsed by, or affiliated with Meta in any way. This project is maintained independently and uses Meta's public APIs according to their terms of service. Meta, Facebook, Instagram, and other Meta brand names are trademarks of their respective owners.
6 |
7 | [](https://github.com/user-attachments/assets/3e605cee-d289-414b-814c-6299e7f3383e)
8 |
9 | [](https://lobehub.com/mcp/nictuku-meta-ads-mcp)
10 |
11 | mcp-name: co.pipeboard/meta-ads-mcp
12 |
13 | ## Community & Support
14 |
15 | - [Discord](https://discord.gg/YzMwQ8zrjr). Join the community.
16 | - [Email Support](mailto:[email protected]). Email us for support.
17 |
18 | ## Table of Contents
19 |
20 | - [🚀 Getting started with Remote MCP (Recommended for Marketers)](#getting-started-with-remote-mcp-recommended)
21 | - [Local Installation (Technical Users Only)](#local-installation-technical-users-only)
22 | - [Features](#features)
23 | - [Configuration](#configuration)
24 | - [Available MCP Tools](#available-mcp-tools)
25 | - [Licensing](#licensing)
26 | - [Privacy and Security](#privacy-and-security)
27 | - [Testing](#testing)
28 | - [Troubleshooting](#troubleshooting)
29 |
30 | ## Getting started with Remote MCP (Recommended)
31 |
32 | The fastest and most reliable way to get started is to **[🚀 Get started with our Meta Ads Remote MCP](https://pipeboard.co)**. Our cloud service uses streamable HTTP transport for reliable, scalable access to Meta Ads data. No technical setup required - just connect and start analyzing your ad campaigns with AI!
33 |
34 | ### For Claude Pro/Max Users
35 |
36 | 1. Go to [claude.ai/settings/integrations](https://claude.ai/settings/integrations) (requires Claude Pro or Max)
37 | 2. Click "Add Integration" and enter:
38 | - **Name**: "Pipeboard Meta Ads" (or any name you prefer)
39 | - **Integration URL**: `https://mcp.pipeboard.co/meta-ads-mcp`
40 | 3. Click "Connect" next to the integration and follow the prompts to:
41 | - Login to Pipeboard
42 | - Connect your Facebook Ads account
43 |
44 | That's it! You can now ask Claude to analyze your Meta ad campaigns, get performance insights, and manage your advertising.
45 |
46 | ### For Cursor Users
47 |
48 | Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP, click on "Needs login" to finish the login process.
49 |
50 |
51 | ```json
52 | {
53 | "mcpServers": {
54 | "meta-ads-remote": {
55 | "url": "https://mcp.pipeboard.co/meta-ads-mcp"
56 | }
57 | }
58 | }
59 | ```
60 |
61 | ### For Other MCP Clients
62 |
63 | Use the Remote MCP URL: `https://mcp.pipeboard.co/meta-ads-mcp`
64 |
65 | **[📖 Get detailed setup instructions for your AI client here](https://pipeboard.co)**
66 |
67 | ## Local Installation (Technical Users Only)
68 |
69 | If you're a developer or need to customize the installation, you can run Meta Ads MCP locally. **Most marketers should use the Remote MCP above instead!** For complete technical setup instructions, see our **[Local Installation Guide](LOCAL_INSTALLATION.md)**.
70 |
71 | Meta Ads MCP also supports **streamable HTTP transport**, allowing you to run it as a standalone HTTP API for web applications and custom integrations. See **[Streamable HTTP Setup Guide](STREAMABLE_HTTP_SETUP.md)** for complete instructions.
72 |
73 | ### Quick Local Setup
74 |
75 | ```bash
76 | # Install via uvx (recommended)
77 | uvx meta-ads-mcp
78 |
79 | # Set your Pipeboard token
80 | export PIPEBOARD_API_TOKEN=your_pipeboard_token
81 |
82 | # Add to your MCP client configuration
83 | ```
84 |
85 | For detailed step-by-step instructions, authentication setup, debugging, and troubleshooting, visit **[LOCAL_INSTALLATION.md](LOCAL_INSTALLATION.md)**.
86 |
87 | ## Features
88 |
89 | - **AI-Powered Campaign Analysis**: Let your favorite LLM analyze your campaigns and provide actionable insights on performance
90 | - **Strategic Recommendations**: Receive data-backed suggestions for optimizing ad spend, targeting, and creative content
91 | - **Automated Monitoring**: Ask any MCP-compatible LLM to track performance metrics and alert you about significant changes
92 | - **Budget Optimization**: Get recommendations for reallocating budget to better-performing ad sets
93 | - **Creative Improvement**: Receive feedback on ad copy, imagery, and calls-to-action
94 | - **Dynamic Creative Testing**: Easy API for both simple ads (single headline/description) and advanced A/B testing (multiple headlines/descriptions)
95 | - **Campaign Management**: Request changes to campaigns, ad sets, and ads (all changes require explicit confirmation)
96 | - **Cross-Platform Integration**: Works with Facebook, Instagram, and all Meta ad platforms
97 | - **Universal LLM Support**: Compatible with any MCP client including Claude Desktop, Cursor, Cherry Studio, and more
98 | - **Enhanced Search**: Generic search function includes page searching when queries mention "page" or "pages"
99 | - **Simple Authentication**: Easy setup with secure OAuth authentication
100 | - **Cross-Platform Support**: Works on Windows, macOS, and Linux
101 |
102 | ## Configuration
103 |
104 | ### Remote MCP (Recommended)
105 |
106 | **[✨ Get started with Remote MCP here](https://pipeboard.co)** - no technical setup required! Just connect your Facebook Ads account and start asking AI to analyze your campaigns.
107 |
108 | ### Local Installation (Technical Users)
109 |
110 | For local installation configuration, authentication options, and advanced technical setup, see our **[Local Installation Guide](LOCAL_INSTALLATION.md)**.
111 |
112 | ### Available MCP Tools
113 |
114 | 1. `mcp_meta_ads_get_ad_accounts`
115 | - Get ad accounts accessible by a user
116 | - Inputs:
117 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
118 | - `user_id`: Meta user ID or "me" for the current user
119 | - `limit`: Maximum number of accounts to return (default: 200)
120 | - Returns: List of accessible ad accounts with their details
121 |
122 | 2. `mcp_meta_ads_get_account_info`
123 | - Get detailed information about a specific ad account
124 | - Inputs:
125 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
126 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
127 | - Returns: Detailed information about the specified account
128 |
129 | 3. `mcp_meta_ads_get_account_pages`
130 | - Get pages associated with a Meta Ads account
131 | - Inputs:
132 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
133 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX) or "me" for the current user's pages
134 | - Returns: List of pages associated with the account, useful for ad creation and management
135 |
136 | 4. `mcp_meta_ads_get_campaigns`
137 | - Get campaigns for a Meta Ads account with optional filtering
138 | - Inputs:
139 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
140 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
141 | - `limit`: Maximum number of campaigns to return (default: 10)
142 | - `status_filter`: Filter by status (empty for all, or 'ACTIVE', 'PAUSED', etc.)
143 | - Returns: List of campaigns matching the criteria
144 |
145 | 5. `mcp_meta_ads_get_campaign_details`
146 | - Get detailed information about a specific campaign
147 | - Inputs:
148 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
149 | - `campaign_id`: Meta Ads campaign ID
150 | - Returns: Detailed information about the specified campaign
151 |
152 | 6. `mcp_meta_ads_create_campaign`
153 | - Create a new campaign in a Meta Ads account
154 | - Inputs:
155 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
156 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
157 | - `name`: Campaign name
158 | - `objective`: Campaign objective (ODAX, outcome-based). Must be one of:
159 | - `OUTCOME_AWARENESS`
160 | - `OUTCOME_TRAFFIC`
161 | - `OUTCOME_ENGAGEMENT`
162 | - `OUTCOME_LEADS`
163 | - `OUTCOME_SALES`
164 | - `OUTCOME_APP_PROMOTION`
165 |
166 | Note: Legacy objectives such as `BRAND_AWARENESS`, `LINK_CLICKS`, `CONVERSIONS`, `APP_INSTALLS`, etc. are no longer valid for new campaigns and will cause a 400 error. Use the outcome-based values above. Common mappings:
167 | - `BRAND_AWARENESS` → `OUTCOME_AWARENESS`
168 | - `REACH` → `OUTCOME_AWARENESS`
169 | - `LINK_CLICKS`, `TRAFFIC` → `OUTCOME_TRAFFIC`
170 | - `POST_ENGAGEMENT`, `PAGE_LIKES`, `EVENT_RESPONSES`, `VIDEO_VIEWS` → `OUTCOME_ENGAGEMENT`
171 | - `LEAD_GENERATION` → `OUTCOME_LEADS`
172 | - `CONVERSIONS`, `CATALOG_SALES`, `MESSAGES` (sales-focused flows) → `OUTCOME_SALES`
173 | - `APP_INSTALLS` → `OUTCOME_APP_PROMOTION`
174 | - `status`: Initial campaign status (default: PAUSED)
175 | - `special_ad_categories`: List of special ad categories if applicable
176 | - `daily_budget`: Daily budget in account currency (in cents)
177 | - `lifetime_budget`: Lifetime budget in account currency (in cents)
178 | - `bid_strategy`: Bid strategy. Must be one of: `LOWEST_COST_WITHOUT_CAP`, `LOWEST_COST_WITH_BID_CAP`, `COST_CAP`, `LOWEST_COST_WITH_MIN_ROAS`.
179 | - Returns: Confirmation with new campaign details
180 |
181 | - Example:
182 | ```json
183 | {
184 | "name": "2025 - Bedroom Furniture - Awareness",
185 | "account_id": "act_123456789012345",
186 | "objective": "OUTCOME_AWARENESS",
187 | "special_ad_categories": [],
188 | "status": "PAUSED",
189 | "buying_type": "AUCTION",
190 | "bid_strategy": "LOWEST_COST_WITHOUT_CAP",
191 | "daily_budget": 10000
192 | }
193 | ```
194 |
195 | 7. `mcp_meta_ads_get_adsets`
196 | - Get ad sets for a Meta Ads account with optional filtering by campaign
197 | - Inputs:
198 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
199 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
200 | - `limit`: Maximum number of ad sets to return (default: 10)
201 | - `campaign_id`: Optional campaign ID to filter by
202 | - Returns: List of ad sets matching the criteria
203 |
204 | 8. `mcp_meta_ads_get_adset_details`
205 | - Get detailed information about a specific ad set
206 | - Inputs:
207 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
208 | - `adset_id`: Meta Ads ad set ID
209 | - Returns: Detailed information about the specified ad set
210 |
211 | 9. `mcp_meta_ads_create_adset`
212 | - Create a new ad set in a Meta Ads account
213 | - Inputs:
214 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
215 | - `campaign_id`: Meta Ads campaign ID this ad set belongs to
216 | - `name`: Ad set name
217 | - `status`: Initial ad set status (default: PAUSED)
218 | - `daily_budget`: Daily budget in account currency (in cents) as a string
219 | - `lifetime_budget`: Lifetime budget in account currency (in cents) as a string
220 | - `targeting`: Targeting specifications (e.g., age, location, interests)
221 | - `optimization_goal`: Conversion optimization goal (e.g., 'LINK_CLICKS')
222 | - `billing_event`: How you're charged (e.g., 'IMPRESSIONS')
223 | - `bid_amount`: Bid amount in account currency (in cents)
224 | - `bid_strategy`: Bid strategy (e.g., 'LOWEST_COST')
225 | - `start_time`, `end_time`: Optional start/end times (ISO 8601)
226 | - `access_token` (optional): Meta API access token
227 | - Returns: Confirmation with new ad set details
228 |
229 | 10. `mcp_meta_ads_get_ads`
230 | - Get ads for a Meta Ads account with optional filtering
231 | - Inputs:
232 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
233 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
234 | - `limit`: Maximum number of ads to return (default: 10)
235 | - `campaign_id`: Optional campaign ID to filter by
236 | - `adset_id`: Optional ad set ID to filter by
237 | - Returns: List of ads matching the criteria
238 |
239 | 11. `mcp_meta_ads_create_ad`
240 | - Create a new ad with an existing creative
241 | - Inputs:
242 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
243 | - `name`: Ad name
244 | - `adset_id`: Ad set ID where this ad will be placed
245 | - `creative_id`: ID of an existing creative to use
246 | - `status`: Initial ad status (default: PAUSED)
247 | - `bid_amount`: Optional bid amount (in cents)
248 | - `tracking_specs`: Optional tracking specifications
249 | - `access_token` (optional): Meta API access token
250 | - Returns: Confirmation with new ad details
251 |
252 | 12. `mcp_meta_ads_get_ad_details`
253 | - Get detailed information about a specific ad
254 | - Inputs:
255 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
256 | - `ad_id`: Meta Ads ad ID
257 | - Returns: Detailed information about the specified ad
258 |
259 | 13. `mcp_meta_ads_get_ad_creatives`
260 | - Get creative details for a specific ad
261 | - Inputs:
262 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
263 | - `ad_id`: Meta Ads ad ID
264 | - Returns: Creative details including text, images, and URLs
265 |
266 | 14. `mcp_meta_ads_create_ad_creative`
267 | - Create a new ad creative using an uploaded image hash
268 | - Inputs:
269 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
270 | - `name`: Creative name
271 | - `image_hash`: Hash of the uploaded image
272 | - `page_id`: Facebook Page ID for the ad
273 | - `link_url`: Destination URL
274 | - `message`: Ad copy/text
275 | - `headline`: Single headline for simple ads (cannot be used with headlines)
276 | - `headlines`: List of headlines for dynamic creative testing (cannot be used with headline)
277 | - `description`: Single description for simple ads (cannot be used with descriptions)
278 | - `descriptions`: List of descriptions for dynamic creative testing (cannot be used with description)
279 | - `dynamic_creative_spec`: Dynamic creative optimization settings
280 | - `call_to_action_type`: CTA button type (e.g., 'LEARN_MORE')
281 | - `instagram_actor_id`: Optional Instagram account ID
282 | - `access_token` (optional): Meta API access token
283 | - Returns: Confirmation with new creative details
284 |
285 | 15. `mcp_meta_ads_update_ad_creative`
286 | - Update an existing ad creative with new content or settings
287 | - Inputs:
288 | - `creative_id`: Meta Ads creative ID to update
289 | - `name`: New creative name
290 | - `message`: New ad copy/text
291 | - `headline`: Single headline for simple ads (cannot be used with headlines)
292 | - `headlines`: New list of headlines for dynamic creative testing (cannot be used with headline)
293 | - `description`: Single description for simple ads (cannot be used with descriptions)
294 | - `descriptions`: New list of descriptions for dynamic creative testing (cannot be used with description)
295 | - `dynamic_creative_spec`: New dynamic creative optimization settings
296 | - `call_to_action_type`: New call to action button type
297 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
298 | - Returns: Confirmation with updated creative details
299 |
300 | 16. `mcp_meta_ads_upload_ad_image`
301 | - Upload an image to use in Meta Ads creatives
302 | - Inputs:
303 | - `account_id`: Meta Ads account ID (format: act_XXXXXXXXX)
304 | - `image_path`: Path to the image file to upload
305 | - `name`: Optional name for the image
306 | - `access_token` (optional): Meta API access token
307 | - Returns: JSON response with image details including hash
308 |
309 | 17. `mcp_meta_ads_get_ad_image`
310 | - Get, download, and visualize a Meta ad image in one step
311 | - Inputs:
312 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
313 | - `ad_id`: Meta Ads ad ID
314 | - Returns: The ad image ready for direct visual analysis
315 |
316 | 18. `mcp_meta_ads_update_ad`
317 | - Update an ad with new settings
318 | - Inputs:
319 | - `ad_id`: Meta Ads ad ID
320 | - `status`: Update ad status (ACTIVE, PAUSED, etc.)
321 | - `bid_amount`: Bid amount in account currency (in cents for USD)
322 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
323 | - Returns: Confirmation with updated ad details and a confirmation link
324 |
325 | 19. `mcp_meta_ads_update_adset`
326 | - Update an ad set with new settings including frequency caps
327 | - Inputs:
328 | - `adset_id`: Meta Ads ad set ID
329 | - `frequency_control_specs`: List of frequency control specifications
330 | - `bid_strategy`: Bid strategy (e.g., 'LOWEST_COST_WITH_BID_CAP')
331 | - `bid_amount`: Bid amount in account currency (in cents for USD)
332 | - `status`: Update ad set status (ACTIVE, PAUSED, etc.)
333 | - `targeting`: Targeting specifications including targeting_automation
334 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
335 | - Returns: Confirmation with updated ad set details and a confirmation link
336 |
337 | 20. `mcp_meta_ads_get_insights`
338 | - Get performance insights for a campaign, ad set, ad or account
339 | - Inputs:
340 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
341 | - `object_id`: ID of the campaign, ad set, ad or account
342 | - `time_range`: Time range for insights (default: maximum)
343 | - `breakdown`: Optional breakdown dimension (e.g., age, gender, country)
344 | - `level`: Level of aggregation (ad, adset, campaign, account)
345 | - Returns: Performance metrics for the specified object
346 |
347 | 21. `mcp_meta_ads_get_login_link`
348 | - Get a clickable login link for Meta Ads authentication
349 | - Inputs:
350 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
351 | - Returns: A clickable resource link for Meta authentication
352 |
353 | 22. `mcp_meta_ads_create_budget_schedule`
354 | - Create a budget schedule for a Meta Ads campaign
355 | - Inputs:
356 | - `campaign_id`: Meta Ads campaign ID
357 | - `budget_value`: Amount of budget increase
358 | - `budget_value_type`: Type of budget value ("ABSOLUTE" or "MULTIPLIER")
359 | - `time_start`: Unix timestamp for when the high demand period should start
360 | - `time_end`: Unix timestamp for when the high demand period should end
361 | - `access_token` (optional): Meta API access token
362 | - Returns: JSON string with the ID of the created budget schedule or an error message
363 |
364 | 23. `mcp_meta_ads_search_interests`
365 | - Search for interest targeting options by keyword
366 | - Inputs:
367 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
368 | - `query`: Search term for interests (e.g., "baseball", "cooking", "travel")
369 | - `limit`: Maximum number of results to return (default: 25)
370 | - Returns: Interest data with id, name, audience_size, and path fields
371 |
372 | 24. `mcp_meta_ads_get_interest_suggestions`
373 | - Get interest suggestions based on existing interests
374 | - Inputs:
375 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
376 | - `interest_list`: List of interest names to get suggestions for (e.g., ["Basketball", "Soccer"])
377 | - `limit`: Maximum number of suggestions to return (default: 25)
378 | - Returns: Suggested interests with id, name, audience_size, and description fields
379 |
380 | 25. `mcp_meta_ads_validate_interests`
381 | - Validate interest names or IDs for targeting
382 | - Inputs:
383 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
384 | - `interest_list`: List of interest names to validate (e.g., ["Japan", "Basketball"])
385 | - `interest_fbid_list`: List of interest IDs to validate (e.g., ["6003700426513"])
386 | - Returns: Validation results showing valid status and audience_size for each interest
387 |
388 | 26. `mcp_meta_ads_search_behaviors`
389 | - Get all available behavior targeting options
390 | - Inputs:
391 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
392 | - `limit`: Maximum number of results to return (default: 50)
393 | - Returns: Behavior targeting options with id, name, audience_size bounds, path, and description
394 |
395 | 27. `mcp_meta_ads_search_demographics`
396 | - Get demographic targeting options
397 | - Inputs:
398 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
399 | - `demographic_class`: Type of demographics ('demographics', 'life_events', 'industries', 'income', 'family_statuses', 'user_device', 'user_os')
400 | - `limit`: Maximum number of results to return (default: 50)
401 | - Returns: Demographic targeting options with id, name, audience_size bounds, path, and description
402 |
403 | 28. `mcp_meta_ads_search_geo_locations`
404 | - Search for geographic targeting locations
405 | - Inputs:
406 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
407 | - `query`: Search term for locations (e.g., "New York", "California", "Japan")
408 | - `location_types`: Types of locations to search (['country', 'region', 'city', 'zip', 'geo_market', 'electoral_district'])
409 | - `limit`: Maximum number of results to return (default: 25)
410 | - Returns: Location data with key, name, type, and geographic hierarchy information
411 |
412 | 29. `mcp_meta_ads_search` (Enhanced)
413 | - Generic search across accounts, campaigns, ads, and pages
414 | - Automatically includes page searching when query mentions "page" or "pages"
415 | - Inputs:
416 | - `access_token` (optional): Meta API access token (will use cached token if not provided)
417 | - `query`: Search query string (e.g., "Injury Payouts pages", "active campaigns")
418 | - Returns: List of matching record IDs in ChatGPT-compatible format
419 |
420 | ## Licensing
421 |
422 | Meta Ads MCP is licensed under the [Business Source License 1.1](LICENSE), which means:
423 |
424 | - ✅ **Free to use** for individual and business purposes
425 | - ✅ **Modify and customize** as needed
426 | - ✅ **Redistribute** to others
427 | - ✅ **Becomes fully open source** (Apache 2.0) on January 1, 2029
428 |
429 | The only restriction is that you cannot offer this as a competing hosted service. For questions about commercial licensing, please contact us.
430 |
431 | ## Privacy and Security
432 |
433 | Meta Ads MCP follows security best practices with secure token management and automatic authentication handling.
434 |
435 | - **Remote MCP**: All authentication is handled securely in the cloud - no local token storage required
436 | - **Local Installation**: Tokens are cached securely on your local machine - see [Local Installation Guide](LOCAL_INSTALLATION.md) for details
437 |
438 | ## Testing
439 |
440 | ### Basic Testing
441 |
442 | Test your Meta Ads MCP connection with any MCP client:
443 |
444 | 1. **Verify Account Access**: Ask your LLM to use `mcp_meta_ads_get_ad_accounts`
445 | 2. **Check Account Details**: Use `mcp_meta_ads_get_account_info` with your account ID
446 | 3. **List Campaigns**: Try `mcp_meta_ads_get_campaigns` to see your ad campaigns
447 |
448 | For detailed local installation testing, see [Local Installation Guide](LOCAL_INSTALLATION.md).
449 |
450 | ## Troubleshooting
451 |
452 | ### 💡 Quick Fix: Skip the Technical Setup!
453 |
454 | The easiest way to avoid any setup issues is to **[🎯 use our Remote MCP instead](https://pipeboard.co)**. No downloads, no configuration - just connect your ads account and start getting AI insights on your campaigns immediately!
455 |
456 | ### Local Installation Issues
457 |
458 | For comprehensive troubleshooting, debugging, and local installation issues, see our **[Local Installation Guide](LOCAL_INSTALLATION.md)** which includes:
459 |
460 | - Authentication troubleshooting
461 | - Installation issues and solutions
462 | - API error resolution
463 | - Debug logs and diagnostic commands
464 | - Performance optimization tips
465 |
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 | # Tests package for Meta Ads MCP
```
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
```python
1 | """Setup script for meta-ads-mcp package."""
2 |
3 | from setuptools import setup
4 |
5 | if __name__ == "__main__":
6 | setup()
```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
```
1 | httpx>=0.26.0
2 | mcp[cli]==1.12.2
3 | python-dotenv>=1.1.0
4 | requests>=2.32.3
5 | Pillow>=10.0.0
6 | pathlib>=1.0.1
7 | python-dateutil>=2.8.2
8 |
```
--------------------------------------------------------------------------------
/meta_ads_mcp/__main__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Meta Ads MCP - Main Entry Point
3 |
4 | This module allows the package to be executed directly via `python -m meta_ads_mcp`
5 | """
6 |
7 | from meta_ads_mcp.core.server import main
8 |
9 | if __name__ == "__main__":
10 | main()
```
--------------------------------------------------------------------------------
/future_improvements.md:
--------------------------------------------------------------------------------
```markdown
1 | # Future Improvements for Meta Ads MCP
2 |
3 | ## Note about Meta Ads development work
4 |
5 | If you update the MCP server code, please note that *I* have to restart the MCP server. After the server code is changed, ask me to restart it and then proceed with your testing after I confirm it's restarted.
6 |
7 | ## Access token should remain internal only
8 |
9 | Don't share it ever with the LLM, only update the auth cache.
10 |
11 | Future improvements can be added to this file as needed.
12 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM python:3.11-slim
2 |
3 | # Install system dependencies
4 | RUN apt-get update && \
5 | apt-get install -y --no-install-recommends gcc && \
6 | rm -rf /var/lib/apt/lists/*
7 |
8 | # Set working directory
9 | WORKDIR /app
10 |
11 | # Install uv
12 | RUN pip install --upgrade pip && \
13 | pip install uv
14 |
15 | # Copy requirements file
16 | COPY requirements.txt .
17 |
18 | # Install dependencies using uv with --system flag
19 | RUN uv pip install --system -r requirements.txt
20 |
21 | # Copy the rest of the application
22 | COPY . .
23 |
24 | # Command to run the Meta Ads MCP server
25 | CMD ["python", "-m", "meta_ads_mcp"]
```
--------------------------------------------------------------------------------
/server.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json",
3 | "name": "co.pipeboard/meta-ads-mcp",
4 | "description": "Facebook / Meta Ads automation with AI: analyze performance, test creatives, optimize spend.",
5 | "version": "1.0.15",
6 | "remotes": [
7 | {
8 | "type": "streamable-http",
9 | "url": "https://mcp.pipeboard.co/meta-ads-mcp"
10 | }
11 | ],
12 | "packages": [
13 | {
14 | "registryType": "pypi",
15 | "identifier": "meta-ads-mcp",
16 | "version": "1.0.15",
17 | "transport": {
18 | "type": "stdio"
19 | }
20 | }
21 | ]
22 | }
23 |
24 |
```
--------------------------------------------------------------------------------
/tests/test_openai.py:
--------------------------------------------------------------------------------
```python
1 | import os
2 | import pytest
3 |
4 | # Skip this test entirely if the optional 'openai' dependency is not installed
5 | openai = pytest.importorskip("openai", reason="openai package not installed")
6 |
7 |
8 | @pytest.mark.skipif(
9 | not os.getenv("PIPEBOARD_API_TOKEN"),
10 | reason="PIPEBOARD_API_TOKEN not set - skipping OpenAI integration test"
11 | )
12 | def test_openai_mcp_integration():
13 | """Test OpenAI integration with Meta Ads MCP via Pipeboard."""
14 | client = openai.OpenAI()
15 |
16 | resp = client.responses.create(
17 | model="gpt-4.1",
18 | tools=[{
19 | "type": "mcp",
20 | "server_label": "meta-ads",
21 | "server_url": "https://mcp.pipeboard.co/meta-ads-mcp",
22 | "headers": {
23 | "Authorization": f"Bearer {os.getenv('PIPEBOARD_API_TOKEN')}"
24 | },
25 | "require_approval": "never",
26 | }],
27 | input="What are my meta ad accounts? Do not pass access_token since auth is already done.",
28 | )
29 |
30 | assert resp.output_text is not None
31 | print(resp.output_text)
32 |
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | startCommand:
2 | type: stdio
3 | configSchema:
4 | type: object
5 | properties:
6 | pipeboardApiToken:
7 | type: string
8 | description: "Pipeboard API token for Meta authentication (recommended). Get your free token at https://pipeboard.co"
9 | metaAppId:
10 | type: string
11 | description: "Meta App ID (Client ID) for direct OAuth method (only needed if not using Pipeboard authentication)"
12 | required: []
13 | commandFunction: |
14 | (config) => {
15 | const env = {};
16 | const args = ["-m", "meta_ads_mcp"];
17 |
18 | // Add Pipeboard API token to environment if provided (recommended auth method)
19 | if (config.pipeboardApiToken) {
20 | env.PIPEBOARD_API_TOKEN = config.pipeboardApiToken;
21 | }
22 |
23 | // Add Meta App ID as command-line argument if provided (alternative auth method)
24 | if (config.metaAppId) {
25 | args.push("--app-id", config.metaAppId);
26 | }
27 |
28 | return {
29 | command: 'python',
30 | args: args,
31 | env: env
32 | };
33 | }
34 | remotes:
35 | - type: streamable-http
36 | url: "https://mcp.pipeboard.co/meta-ads-mcp"
```
--------------------------------------------------------------------------------
/.github/workflows/publish-mcp.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Publish to MCP Registry (manual)
2 |
3 | on:
4 | # This workflow is kept for manual runs only. The normal release flow
5 | # is handled by the consolidated release workflow.
6 | workflow_dispatch:
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | id-token: write
13 | contents: read
14 |
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 |
19 | - name: Install MCP Publisher
20 | run: |
21 | curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.1.0/mcp-publisher_1.1.0_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
22 |
23 | - name: Login to MCP Registry (DNS auth)
24 | run: |
25 | echo "${{ secrets.MCP_PRIVATE_KEY }}" > temp_key.pem
26 | PRIVATE_KEY_HEX=$(openssl pkey -in temp_key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')
27 | ./mcp-publisher login dns --domain pipeboard.co --private-key "$PRIVATE_KEY_HEX"
28 | rm -f temp_key.pem
29 |
30 | - name: Publish to MCP Registry
31 | run: ./mcp-publisher publish
32 |
33 |
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/resources.py:
--------------------------------------------------------------------------------
```python
1 | """Resource handling for Meta Ads API."""
2 |
3 | from typing import Dict, Any
4 | import base64
5 | from .utils import ad_creative_images
6 |
7 |
8 | async def list_resources() -> Dict[str, Any]:
9 | """
10 | List all available resources (like ad creative images)
11 |
12 | Returns:
13 | Dictionary with resources list
14 | """
15 | resources = []
16 |
17 | # Add all ad creative images as resources
18 | for resource_id, image_info in ad_creative_images.items():
19 | resources.append({
20 | "uri": f"meta-ads://images/{resource_id}",
21 | "mimeType": image_info["mime_type"],
22 | "name": image_info["name"]
23 | })
24 |
25 | return {"resources": resources}
26 |
27 |
28 | async def get_resource(resource_id: str) -> Dict[str, Any]:
29 | """
30 | Get a specific resource by URI
31 |
32 | Args:
33 | resource_id: Unique identifier for the resource
34 |
35 | Returns:
36 | Dictionary with resource data
37 | """
38 | if resource_id in ad_creative_images:
39 | image_info = ad_creative_images[resource_id]
40 | return {
41 | "data": base64.b64encode(image_info["data"]).decode("utf-8"),
42 | "mimeType": image_info["mime_type"]
43 | }
44 |
45 | # Resource not found
46 | return {"error": f"Resource not found: {resource_id}"}
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "meta-ads-mcp"
7 | version = "1.0.15"
8 | description = "Model Context Protocol (MCP) server for interacting with Meta Ads API"
9 | readme = "README.md"
10 | requires-python = ">=3.10"
11 | authors = [
12 | {name = "Yves Junqueira", email = "[email protected]"},
13 | ]
14 | keywords = ["meta", "facebook", "ads", "api", "mcp", "claude"]
15 | license = {text = "BUSL-1.1"}
16 | classifiers = [
17 | "Programming Language :: Python :: 3",
18 | "License :: Other/Proprietary License",
19 | "Operating System :: OS Independent",
20 | ]
21 | dependencies = [
22 | "httpx>=0.26.0",
23 | "mcp[cli]==1.12.2",
24 | "python-dotenv>=1.1.0",
25 | "requests>=2.32.3",
26 | "Pillow>=10.0.0",
27 | "pathlib>=1.0.1",
28 | "python-dateutil>=2.8.2",
29 | "pytest>=8.4.1",
30 | "pytest-asyncio>=1.0.0",
31 | ]
32 |
33 | [project.urls]
34 | "Homepage" = "https://github.com/pipeboard-co/meta-ads-mcp"
35 | "Bug Tracker" = "https://github.com/pipeboard-co/meta-ads-mcp/issues"
36 |
37 | [project.scripts]
38 | meta-ads-mcp = "meta_ads_mcp:entrypoint"
39 |
40 | [tool.hatch.build.targets.wheel]
41 | packages = ["meta_ads_mcp"]
42 |
43 | [tool.pytest.ini_options]
44 | markers = [
45 | "e2e: marks tests as end-to-end (requires running MCP server) - excluded from default runs",
46 | ]
47 | addopts = "-v --strict-markers -m 'not e2e'"
48 | testpaths = ["tests"]
49 | asyncio_mode = "auto"
50 |
```
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Test and Build
2 |
3 | on:
4 | push:
5 | branches: [ main, master ]
6 | pull_request:
7 | branches: [ main, master ]
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | python-version: ["3.10", "3.11", "3.12"]
15 |
16 | steps:
17 | - name: Check out code
18 | uses: actions/checkout@v4
19 |
20 | - name: Set up Python ${{ matrix.python-version }}
21 | uses: actions/setup-python@v5
22 | with:
23 | python-version: ${{ matrix.python-version }}
24 |
25 | - name: Install dependencies
26 | run: |
27 | python -m pip install --upgrade pip
28 | pip install build pytest
29 | pip install -e .
30 |
31 | - name: Test package build
32 | run: python -m build
33 |
34 | - name: Test package installation
35 | run: |
36 | pip install dist/*.whl
37 | python -c "import meta_ads_mcp; print('Package imported successfully')"
38 |
39 | validate-version:
40 | runs-on: ubuntu-latest
41 | if: github.event_name == 'pull_request'
42 |
43 | steps:
44 | - name: Check out code
45 | uses: actions/checkout@v4
46 |
47 | - name: Set up Python
48 | uses: actions/setup-python@v5
49 | with:
50 | python-version: "3.10"
51 |
52 | - name: Check version bump
53 | run: |
54 | # This is a simple check - you might want to make it more sophisticated
55 | echo "Current version in pyproject.toml:"
56 | grep "version = " pyproject.toml
```
--------------------------------------------------------------------------------
/meta_ads_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Meta Ads MCP - Python Package
3 |
4 | This package provides a Meta Ads MCP integration
5 | """
6 |
7 | from meta_ads_mcp.core.server import main
8 |
9 | __version__ = "1.0.15"
10 |
11 | __all__ = [
12 | 'get_ad_accounts',
13 | 'get_account_info',
14 | 'get_campaigns',
15 | 'get_campaign_details',
16 | 'create_campaign',
17 | 'get_adsets',
18 | 'get_adset_details',
19 | 'update_adset',
20 | 'get_ads',
21 | 'get_ad_details',
22 | 'get_ad_creatives',
23 | 'get_ad_image',
24 | 'update_ad',
25 | 'get_insights',
26 | # 'get_login_link' is conditionally exported via core.__all__
27 | 'login_cli',
28 | 'main',
29 | 'search_interests',
30 | 'get_interest_suggestions',
31 | 'estimate_audience_size',
32 | 'search_behaviors',
33 | 'search_demographics',
34 | 'search_geo_locations'
35 | ]
36 |
37 | # Import key functions to make them available at package level
38 | from .core import (
39 | get_ad_accounts,
40 | get_account_info,
41 | get_campaigns,
42 | get_campaign_details,
43 | create_campaign,
44 | get_adsets,
45 | get_adset_details,
46 | update_adset,
47 | get_ads,
48 | get_ad_details,
49 | get_ad_creatives,
50 | get_ad_image,
51 | update_ad,
52 | get_insights,
53 | login_cli,
54 | main,
55 | search_interests,
56 | get_interest_suggestions,
57 | estimate_audience_size,
58 | search_behaviors,
59 | search_demographics,
60 | search_geo_locations
61 | )
62 |
63 | # Define a main function to be used as a package entry point
64 | def entrypoint():
65 | """Main entry point for the package when invoked with uvx."""
66 | return main()
67 |
68 | # Re-export main for direct access
69 | main = main
```
--------------------------------------------------------------------------------
/META_API_NOTES.md:
--------------------------------------------------------------------------------
```markdown
1 | # Meta Ads API Notes and Limitations
2 |
3 | ## Frequency Cap Visibility
4 | The Meta Marketing API has some limitations regarding frequency cap visibility:
5 |
6 | 1. **Optimization Goal Dependency**: Frequency cap settings (`frequency_control_specs`) are only visible in API responses for ad sets where the optimization goal is set to REACH. For other optimization goals (like LINK_CLICKS, CONVERSIONS, etc.), the frequency caps will still work but won't be visible through the API.
7 |
8 | 2. **Verifying Frequency Caps**: Since frequency caps may not be directly visible, you can verify they're working by monitoring:
9 | - The frequency metric in ad insights
10 | - The ratio between reach and impressions over time
11 | - The actual frequency cap behavior in the Meta Ads Manager UI
12 |
13 | ## Other API Behaviors to Note
14 |
15 | 1. **Field Visibility**: Some fields may not appear in API responses even when explicitly requested. This doesn't necessarily mean the field isn't set - it may just not be visible through the API.
16 |
17 | 2. **Response Filtering**: The API may filter out empty or default values from responses to reduce payload size. If a field is missing from a response, it might mean:
18 | - The field is not set
19 | - The field has a default value
20 | - The field is not applicable for the current configuration
21 |
22 | 3. **Best Practices**:
23 | - Always verify important changes through both the API and Meta Ads Manager UI
24 | - Use insights and metrics to confirm behavioral changes when direct field access is limited
25 | - Consider the optimization goal when setting up features like frequency caps
26 |
27 | ## Updates and Changes
28 | Meta frequently updates their API behavior. These notes will be updated as we discover new limitations or changes in the API's behavior.
```
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Pytest configuration for Meta Ads MCP tests
3 |
4 | This file provides common fixtures and configuration for all tests.
5 | """
6 |
7 | import pytest
8 | import requests
9 | import time
10 | import os
11 |
12 |
13 | @pytest.fixture(scope="session")
14 | def server_url():
15 | """Default server URL for tests"""
16 | return os.environ.get("MCP_TEST_SERVER_URL", "http://localhost:8080")
17 |
18 |
19 | @pytest.fixture(scope="session")
20 | def check_server_running(server_url):
21 | """
22 | Check if the MCP server is running before running tests.
23 |
24 | This fixture will skip tests if the server is not available.
25 | """
26 | try:
27 | response = requests.get(f"{server_url}/", timeout=5)
28 | # We expect 404 for root path, but it means server is running
29 | if response.status_code not in [200, 404]:
30 | pytest.skip(f"MCP server not responding correctly at {server_url}")
31 | return True
32 | except requests.exceptions.RequestException:
33 | pytest.skip(
34 | f"MCP server not running at {server_url}. "
35 | f"Start with: python -m meta_ads_mcp --transport streamable-http"
36 | )
37 |
38 |
39 | @pytest.fixture
40 | def test_headers():
41 | """Common test headers for HTTP requests"""
42 | return {
43 | "Content-Type": "application/json",
44 | "Accept": "application/json, text/event-stream",
45 | "User-Agent": "MCP-Test-Client/1.0"
46 | }
47 |
48 |
49 | @pytest.fixture
50 | def pipeboard_auth_headers(test_headers):
51 | """Headers with Pipeboard authentication token"""
52 | headers = test_headers.copy()
53 | headers["Authorization"] = "Bearer test_pipeboard_token_12345"
54 | return headers
55 |
56 |
57 | @pytest.fixture
58 | def meta_app_auth_headers(test_headers):
59 | """Headers with Meta app ID authentication"""
60 | headers = test_headers.copy()
61 | headers["X-META-APP-ID"] = "123456789012345"
62 | return headers
```
--------------------------------------------------------------------------------
/tests/test_create_simple_creative_e2e.py:
--------------------------------------------------------------------------------
```python
1 | """End-to-end test for creating simple creatives with singular headline/description."""
2 |
3 | import pytest
4 | import json
5 | import os
6 | from meta_ads_mcp.core.ads import create_ad_creative
7 |
8 |
9 | @pytest.mark.skip(reason="Requires authentication - run manually with: pytest tests/test_create_simple_creative_e2e.py -v")
10 | @pytest.mark.asyncio
11 | async def test_create_simple_creative_with_real_api():
12 | """Test creating a simple creative with singular headline/description using real Meta API."""
13 |
14 | # Account and image details from user
15 | account_id = "act_3182643988557192"
16 | image_hash = "ca228ac8ff3a66dca9435c90dd6953d6"
17 |
18 | # Create a simple creative with singular headline and description
19 | result = await create_ad_creative(
20 | account_id=account_id,
21 | image_hash=image_hash,
22 | name="E2E Test - Simple Creative",
23 | link_url="https://example.com/",
24 | message="This is a test message for the ad.",
25 | headline="Test Headline",
26 | description="Test description for ad.",
27 | call_to_action_type="LEARN_MORE"
28 | )
29 |
30 | print("\n=== API Response ===")
31 | print(result)
32 |
33 | result_data = json.loads(result)
34 |
35 | # Check if there's an error
36 | if "error" in result_data:
37 | pytest.fail(f"Creative creation failed: {result_data['error']}")
38 |
39 | # Verify success
40 | assert "success" in result_data or "creative_id" in result_data or "id" in result_data, \
41 | f"Expected success response, got: {result_data}"
42 |
43 | print("\n✅ Simple creative created successfully!")
44 |
45 | if "creative_id" in result_data:
46 | print(f"Creative ID: {result_data['creative_id']}")
47 | elif "details" in result_data and "id" in result_data["details"]:
48 | print(f"Creative ID: {result_data['details']['id']}")
49 |
50 |
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Core functionality for Meta Ads API MCP package."""
2 |
3 | from .server import mcp_server
4 | from .accounts import get_ad_accounts, get_account_info
5 | from .campaigns import get_campaigns, get_campaign_details, create_campaign
6 | from .adsets import get_adsets, get_adset_details, update_adset
7 | from .ads import get_ads, get_ad_details, get_ad_creatives, get_ad_image, update_ad
8 | from .insights import get_insights
9 | from . import authentication # Import module to register conditional auth tools
10 | from .server import login_cli, main
11 | from .auth import login
12 | from . import ads_library # Import module to register conditional tools
13 | from .budget_schedules import create_budget_schedule
14 | from .targeting import search_interests, get_interest_suggestions, estimate_audience_size, search_behaviors, search_demographics, search_geo_locations
15 | from . import reports # Import module to register conditional tools
16 | from . import duplication # Import module to register conditional duplication tools
17 | from .openai_deep_research import search, fetch # OpenAI MCP Deep Research tools
18 |
19 | __all__ = [
20 | 'mcp_server',
21 | 'get_ad_accounts',
22 | 'get_account_info',
23 | 'get_campaigns',
24 | 'get_campaign_details',
25 | 'create_campaign',
26 | 'get_adsets',
27 | 'get_adset_details',
28 | 'update_adset',
29 | 'get_ads',
30 | 'get_ad_details',
31 | 'get_ad_creatives',
32 | 'get_ad_image',
33 | 'update_ad',
34 | 'get_insights',
35 | # Note: 'get_login_link' is registered conditionally by the authentication module
36 | 'login_cli',
37 | 'login',
38 | 'main',
39 | 'create_budget_schedule',
40 | 'search_interests',
41 | 'get_interest_suggestions',
42 | 'estimate_audience_size',
43 | 'search_behaviors',
44 | 'search_demographics',
45 | 'search_geo_locations',
46 | 'search', # OpenAI MCP Deep Research search tool
47 | 'fetch', # OpenAI MCP Deep Research fetch tool
48 | ]
```
--------------------------------------------------------------------------------
/meta_ads_auth.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 | echo "Starting Meta Ads MCP with Pipeboard authentication..."
3 |
4 | # Set the Pipeboard API token as an environment variable
5 | export PIPEBOARD_API_TOKEN="pk_8ee8c727644d4d32b646ddcf16f2385e"
6 |
7 | # Check if the Pipeboard server is running and can handle meta auth
8 | echo "Checking if Pipeboard meta auth endpoint is available..."
9 | curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/api/meta" > /dev/null
10 | if [ $? -ne 0 ]; then
11 | echo "Warning: Could not connect to Pipeboard meta endpoint"
12 | echo "Please make sure the Pipeboard server is running before proceeding."
13 | echo "You can start it with: cd /path/to/pipeboard && npm run dev"
14 | echo ""
15 | echo "As a test, you can try running this command separately:"
16 | echo "curl -X POST \"http://localhost:3000/api/meta/auth?api_token=pk_8ee8c727644d4d32b646ddcf16f2385e\" -H \"Content-Type: application/json\""
17 | echo ""
18 | read -p "Press Enter to continue anyway or Ctrl+C to abort..."
19 | fi
20 |
21 | # Try direct auth to test the endpoint
22 | echo "Testing direct authentication with Pipeboard..."
23 | AUTH_RESPONSE=$(curl -s -X POST "http://localhost:3000/api/meta/auth?api_token=pk_8ee8c727644d4d32b646ddcf16f2385e" -H "Content-Type: application/json")
24 | if [[ $AUTH_RESPONSE == *"loginUrl"* ]]; then
25 | echo "Authentication endpoint working correctly!"
26 | LOGIN_URL=$(echo $AUTH_RESPONSE | grep -o 'https://[^"]*')
27 | echo "Login URL: $LOGIN_URL"
28 |
29 | # Open the browser directly as a fallback
30 | echo "Opening browser to login URL (as a fallback)..."
31 | if [ "$(uname)" == "Darwin" ]; then
32 | # macOS
33 | open "$LOGIN_URL"
34 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then
35 | # Linux
36 | xdg-open "$LOGIN_URL" || firefox "$LOGIN_URL" || google-chrome "$LOGIN_URL" || echo "Could not open browser automatically"
37 | else
38 | # Windows or others
39 | echo "Please open this URL in your browser manually: $LOGIN_URL"
40 | fi
41 |
42 | echo "After authorizing in your browser, the MCP script will retrieve the token."
43 | else
44 | echo "Warning: Authentication endpoint test failed!"
45 | echo "Response: $AUTH_RESPONSE"
46 | read -p "Press Enter to continue anyway or Ctrl+C to abort..."
47 | fi
48 |
49 | # Run the meta-ads-mcp package
50 | echo "Running Meta Ads MCP..."
51 | python -m meta_ads_mcp
52 |
53 | echo "Meta Ads MCP server is now running."
54 | echo "If you had to manually authenticate, the token should be cached now."
55 |
```
--------------------------------------------------------------------------------
/tests/test_is_dynamic_creative_adset.py:
--------------------------------------------------------------------------------
```python
1 | import json
2 | import pytest
3 | from unittest.mock import AsyncMock, patch
4 |
5 | from meta_ads_mcp.core.adsets import create_adset, update_adset, get_adsets, get_adset_details
6 |
7 |
8 | @pytest.mark.asyncio
9 | async def test_create_adset_includes_is_dynamic_creative_true():
10 | sample_response = {"id": "adset_1", "name": "DC Adset"}
11 | with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
12 | mock_api.return_value = sample_response
13 |
14 | result = await create_adset(
15 | account_id="act_123",
16 | campaign_id="cmp_1",
17 | name="DC Adset",
18 | optimization_goal="LINK_CLICKS",
19 | billing_event="IMPRESSIONS",
20 | targeting={"geo_locations": {"countries": ["US"]}},
21 | is_dynamic_creative=True,
22 | access_token="test_token",
23 | )
24 |
25 | assert json.loads(result)["id"] == "adset_1"
26 | # Verify param was sent as string boolean
27 | call_args = mock_api.call_args
28 | params = call_args[0][2]
29 | assert params["is_dynamic_creative"] == "true"
30 |
31 |
32 | @pytest.mark.asyncio
33 | async def test_update_adset_includes_is_dynamic_creative_false():
34 | sample_response = {"success": True}
35 | with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
36 | mock_api.return_value = sample_response
37 |
38 | result = await update_adset(
39 | adset_id="120",
40 | is_dynamic_creative=False,
41 | access_token="test_token",
42 | )
43 |
44 | assert json.loads(result)["success"] is True
45 | call_args = mock_api.call_args
46 | params = call_args[0][2]
47 | assert params["is_dynamic_creative"] == "false"
48 |
49 |
50 | @pytest.mark.asyncio
51 | async def test_get_adsets_fields_include_is_dynamic_creative():
52 | sample_response = {"data": []}
53 | with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
54 | mock_api.return_value = sample_response
55 |
56 | result = await get_adsets(account_id="act_123", access_token="test_token", limit=1)
57 | assert json.loads(result)["data"] == []
58 |
59 | call_args = mock_api.call_args
60 | params = call_args[0][2]
61 | assert "is_dynamic_creative" in params.get("fields", "")
62 |
63 |
64 | @pytest.mark.asyncio
65 | async def test_get_adset_details_fields_include_is_dynamic_creative():
66 | sample_response = {"id": "120", "name": "Test", "is_dynamic_creative": True}
67 | with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
68 | mock_api.return_value = sample_response
69 |
70 | result = await get_adset_details(adset_id="120", access_token="test_token")
71 | assert json.loads(result)["id"] == "120"
72 |
73 | call_args = mock_api.call_args
74 | params = call_args[0][2]
75 | assert "is_dynamic_creative" in params.get("fields", "")
76 |
77 |
78 |
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/budget_schedules.py:
--------------------------------------------------------------------------------
```python
1 | """Budget Schedule-related functionality for Meta Ads API."""
2 |
3 | import json
4 | from typing import Optional, Dict, Any
5 |
6 | from .api import meta_api_tool, make_api_request
7 | from .server import mcp_server
8 | # Assuming no other specific dependencies from adsets.py are needed for this single function.
9 | # If other utilities from adsets.py (like get_ad_accounts) were needed, they'd be imported here.
10 |
11 | @mcp_server.tool()
12 | @meta_api_tool
13 | async def create_budget_schedule(
14 | campaign_id: str,
15 | budget_value: int,
16 | budget_value_type: str,
17 | time_start: int,
18 | time_end: int,
19 | access_token: Optional[str] = None
20 | ) -> str:
21 | """
22 | Create a budget schedule for a Meta Ads campaign.
23 |
24 | Allows scheduling budget increases based on anticipated high-demand periods.
25 | The times should be provided as Unix timestamps.
26 |
27 | Args:
28 | campaign_id: Meta Ads campaign ID.
29 | budget_value: Amount of budget increase. Interpreted based on budget_value_type.
30 | budget_value_type: Type of budget value - "ABSOLUTE" or "MULTIPLIER".
31 | time_start: Unix timestamp for when the high demand period should start.
32 | time_end: Unix timestamp for when the high demand period should end.
33 | access_token: Meta API access token (optional - will use cached token if not provided).
34 |
35 | Returns:
36 | A JSON string containing the ID of the created budget schedule or an error message.
37 | """
38 | if not campaign_id:
39 | return json.dumps({"error": "Campaign ID is required"}, indent=2)
40 | if budget_value is None: # Check for None explicitly
41 | return json.dumps({"error": "Budget value is required"}, indent=2)
42 | if not budget_value_type:
43 | return json.dumps({"error": "Budget value type is required"}, indent=2)
44 | if budget_value_type not in ["ABSOLUTE", "MULTIPLIER"]:
45 | return json.dumps({"error": "Invalid budget_value_type. Must be ABSOLUTE or MULTIPLIER"}, indent=2)
46 | if time_start is None: # Check for None explicitly to allow 0
47 | return json.dumps({"error": "Time start is required"}, indent=2)
48 | if time_end is None: # Check for None explicitly to allow 0
49 | return json.dumps({"error": "Time end is required"}, indent=2)
50 |
51 | endpoint = f"{campaign_id}/budget_schedules"
52 |
53 | params = {
54 | "budget_value": budget_value,
55 | "budget_value_type": budget_value_type,
56 | "time_start": time_start,
57 | "time_end": time_end,
58 | }
59 |
60 | try:
61 | data = await make_api_request(endpoint, access_token, params, method="POST")
62 | return json.dumps(data, indent=2)
63 | except Exception as e:
64 | error_msg = str(e)
65 | # Include details about the error and the parameters sent for easier debugging
66 | return json.dumps({
67 | "error": "Failed to create budget schedule",
68 | "details": error_msg,
69 | "campaign_id": campaign_id,
70 | "params_sent": params
71 | }, indent=2)
```
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: "Release: Test, PyPI, MCP"
2 |
3 | on:
4 | release:
5 | types: [published]
6 | # Allow manual triggering for testing
7 | workflow_dispatch:
8 |
9 | jobs:
10 | test_and_build:
11 | name: Test and Build (pre-release gate)
12 | runs-on: ubuntu-latest
13 | permissions:
14 | contents: read
15 |
16 | steps:
17 | - name: Check out code
18 | uses: actions/checkout@v4
19 |
20 | - name: Set up Python
21 | uses: actions/setup-python@v5
22 | with:
23 | python-version: "3.12"
24 |
25 | - name: Install uv
26 | uses: astral-sh/setup-uv@v5
27 |
28 | - name: Install dependencies
29 | run: |
30 | uv sync --all-extras --dev
31 |
32 | - name: Run tests
33 | run: |
34 | uv run pytest -q
35 |
36 | - name: Build wheel and sdist
37 | run: |
38 | uv build
39 |
40 | - name: Validate server.json against schema
41 | run: |
42 | uv run python - <<'PY'
43 | import json, sys, urllib.request
44 | from jsonschema import validate
45 | from jsonschema.exceptions import ValidationError
46 | server = json.load(open('server.json'))
47 | schema_url = server.get('$schema')
48 | with urllib.request.urlopen(schema_url) as r:
49 | schema = json.load(r)
50 | try:
51 | validate(instance=server, schema=schema)
52 | except ValidationError as e:
53 | print('Schema validation failed:', e, file=sys.stderr)
54 | sys.exit(1)
55 | print('server.json is valid')
56 | PY
57 |
58 | publish_pypi:
59 | name: Publish to PyPI
60 | needs: test_and_build
61 | runs-on: ubuntu-latest
62 | environment: release
63 | permissions:
64 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
65 | contents: read
66 |
67 | steps:
68 | - name: Check out code
69 | uses: actions/checkout@v4
70 |
71 | - name: Set up Python
72 | uses: actions/setup-python@v5
73 | with:
74 | python-version: "3.10"
75 |
76 | - name: Install build dependencies
77 | run: |
78 | python -m pip install --upgrade pip
79 | pip install build
80 |
81 | - name: Build package
82 | run: python -m build
83 |
84 | - name: Publish to PyPI
85 | uses: pypa/gh-action-pypi-publish@release/v1
86 | with:
87 | verbose: true
88 |
89 | publish_mcp:
90 | name: Publish to MCP Registry
91 | needs: publish_pypi
92 | runs-on: ubuntu-latest
93 | permissions:
94 | id-token: write
95 | contents: read
96 |
97 | steps:
98 | - name: Check out code
99 | uses: actions/checkout@v4
100 |
101 | - name: Install MCP Publisher
102 | run: |
103 | curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.1.0/mcp-publisher_1.1.0_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
104 |
105 | - name: Login to MCP Registry (DNS auth)
106 | run: |
107 | # Extract private key using official MCP publisher method
108 | echo "${{ secrets.MCP_PRIVATE_KEY }}" > temp_key.pem
109 | PRIVATE_KEY_HEX=$(openssl pkey -in temp_key.pem -noout -text | grep -A3 "priv:" | tail -n +2 | tr -d ' :\n')
110 | ./mcp-publisher login dns --domain pipeboard.co --private-key "$PRIVATE_KEY_HEX"
111 | rm -f temp_key.pem
112 |
113 | - name: Publish to MCP Registry
114 | run: ./mcp-publisher publish
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/ads_library.py:
--------------------------------------------------------------------------------
```python
1 | """Adds Library-related functionality for Meta Ads API."""
2 |
3 | import json
4 | import os
5 | from typing import Optional, List, Dict, Any
6 | from .api import meta_api_tool, make_api_request
7 | from .server import mcp_server
8 |
9 |
10 | # Only register the search_ads_archive function if the environment variable is NOT set
11 | DISABLE_ADS_LIBRARY = bool(os.environ.get("META_ADS_DISABLE_ADS_LIBRARY", ""))
12 |
13 | if not DISABLE_ADS_LIBRARY:
14 | @mcp_server.tool()
15 | @meta_api_tool
16 | async def search_ads_archive(
17 | search_terms: str,
18 | ad_reached_countries: List[str],
19 | access_token: Optional[str] = None,
20 | ad_type: str = "ALL",
21 | limit: int = 25, # Default limit, adjust as needed
22 | fields: str = "ad_creation_time,ad_creative_body,ad_creative_link_caption,ad_creative_link_description,ad_creative_link_title,ad_delivery_start_time,ad_delivery_stop_time,ad_snapshot_url,currency,demographic_distribution,funding_entity,impressions,page_id,page_name,publisher_platform,region_distribution,spend"
23 | ) -> str:
24 | """
25 | Search the Facebook Ads Library archive.
26 |
27 | Args:
28 | search_terms: The search query for ads.
29 | ad_reached_countries: List of country codes (e.g., ["US", "GB"]).
30 | access_token: Meta API access token (optional - will use cached token if not provided).
31 | ad_type: Type of ads to search for (e.g., POLITICAL_AND_ISSUE_ADS, HOUSING_ADS, ALL).
32 | limit: Maximum number of ads to return.
33 | fields: Comma-separated string of fields to retrieve for each ad.
34 |
35 | Example Usage via curl equivalent:
36 | curl -G \\
37 | -d "search_terms='california'" \\
38 | -d "ad_type=POLITICAL_AND_ISSUE_ADS" \\
39 | -d "ad_reached_countries=['US']" \\
40 | -d "fields=ad_snapshot_url,spend" \\
41 | -d "access_token=<ACCESS_TOKEN>" \\
42 | "https://graph.facebook.com/<API_VERSION>/ads_archive"
43 | """
44 | if not access_token:
45 | # Attempt to get token implicitly if not provided - meta_api_tool handles this
46 | pass
47 |
48 | if not search_terms:
49 | return json.dumps({"error": "search_terms parameter is required"}, indent=2)
50 |
51 | if not ad_reached_countries:
52 | return json.dumps({"error": "ad_reached_countries parameter is required"}, indent=2)
53 |
54 | endpoint = "ads_archive"
55 | params = {
56 | "search_terms": search_terms,
57 | "ad_type": ad_type,
58 | "ad_reached_countries": json.dumps(ad_reached_countries), # API expects a JSON array string
59 | "limit": limit,
60 | "fields": fields,
61 | }
62 |
63 | try:
64 | data = await make_api_request(endpoint, access_token, params, method="GET")
65 | return json.dumps(data, indent=2)
66 | except Exception as e:
67 | error_msg = str(e)
68 | # Consider logging the full error for debugging
69 | # print(f"Error calling Ads Library API: {error_msg}")
70 | return json.dumps({
71 | "error": "Failed to search ads archive",
72 | "details": error_msg,
73 | "params_sent": {k: v for k, v in params.items() if k != 'access_token'} # Avoid logging token
74 | }, indent=2)
```
--------------------------------------------------------------------------------
/CUSTOM_META_APP.md:
--------------------------------------------------------------------------------
```markdown
1 | # Using a Custom Meta Developer App
2 |
3 | This guide explains how to use Meta Ads MCP with your own Meta Developer App. Note that this is an alternative method - we recommend using [Pipeboard authentication](README.md) for a simpler setup.
4 |
5 | ## Create a Meta Developer App
6 |
7 | Before using direct Meta authentication, you'll need to set up a Meta Developer App:
8 |
9 | 1. Go to [Meta for Developers](https://developers.facebook.com/) and create a new app
10 | 2. Choose the "Business" app type
11 | 3. In your app settings, add the "Marketing API" product
12 | 4. Configure your app's OAuth redirect URI to include `http://localhost:8888/callback`
13 | 5. Note your App ID (Client ID) for use with the MCP
14 |
15 | ## Installation & Usage
16 |
17 | When using your own Meta app, you'll need to provide the App ID:
18 |
19 | ```bash
20 | # Using uvx
21 | uvx meta-ads-mcp --app-id YOUR_META_ADS_APP_ID
22 |
23 | # Using pip installation
24 | python -m meta_ads_mcp --app-id YOUR_META_ADS_APP_ID
25 | ```
26 |
27 | ## Configuration
28 |
29 | ### Cursor or Claude Desktop Integration
30 |
31 | Add this to your `claude_desktop_config.json` or `~/.cursor/mcp.json`:
32 |
33 | ```json
34 | "mcpServers": {
35 | "meta-ads": {
36 | "command": "uvx",
37 | "args": ["meta-ads-mcp", "--app-id", "YOUR_META_ADS_APP_ID"]
38 | }
39 | }
40 | ```
41 |
42 | ## Authentication Flow
43 |
44 | When using direct Meta OAuth, the MCP uses Meta's OAuth 2.0 authentication flow:
45 |
46 | 1. Starts a local callback server on your machine
47 | 2. Opens a browser window to authenticate with Meta
48 | 3. Asks you to authorize the app
49 | 4. Redirects back to the local server to extract and store the token securely
50 |
51 | ## Environment Variables
52 |
53 | You can use these environment variables instead of command-line arguments:
54 |
55 | ```bash
56 | # Your Meta App ID
57 | export META_APP_ID=your_app_id
58 | uvx meta-ads-mcp
59 |
60 | # Or provide a direct access token (bypasses local cache)
61 | export META_ACCESS_TOKEN=your_access_token
62 | uvx meta-ads-mcp
63 | ```
64 |
65 | ## Testing
66 |
67 | ### CLI Testing
68 |
69 | Run the test script to verify authentication:
70 |
71 | ```bash
72 | # Basic test
73 | python test_meta_ads_auth.py --app-id YOUR_APP_ID
74 |
75 | # Force new login
76 | python test_meta_ads_auth.py --app-id YOUR_APP_ID --force-login
77 | ```
78 |
79 | ### LLM Interface Testing
80 |
81 | When using direct Meta authentication:
82 | 1. Test authentication by calling the `mcp_meta_ads_get_login_link` tool
83 | 2. Verify account access by calling `mcp_meta_ads_get_ad_accounts`
84 | 3. Check specific account details with `mcp_meta_ads_get_account_info`
85 |
86 | ## Troubleshooting
87 |
88 | ### Authentication Issues
89 |
90 | 1. App ID Issues
91 | - If you encounter errors like `(#200) Provide valid app ID`, verify your App ID is correct
92 | - Make sure you've completed the app setup steps above
93 | - Check that your app has the Marketing API product added
94 |
95 | 2. OAuth Flow
96 | - Run with `--force-login` to get a fresh token: `uvx meta-ads-mcp --login --app-id YOUR_APP_ID --force-login`
97 | - Make sure the terminal has permissions to open a browser window
98 | - Check that the callback server can run on port 8888
99 |
100 | 3. Direct Token Usage
101 | - If you have a valid access token, you can bypass the OAuth flow:
102 | - `export META_ACCESS_TOKEN=your_access_token`
103 | - This will ignore the local token cache
104 |
105 | ### API Errors
106 |
107 | If you receive errors from the Meta API:
108 | 1. Verify your app has the Marketing API product added
109 | 2. Ensure the user has appropriate permissions on the ad accounts
110 | 3. Check if there are rate limits or other restrictions on your app
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/accounts.py:
--------------------------------------------------------------------------------
```python
1 | """Account-related functionality for Meta Ads API."""
2 |
3 | import json
4 | from typing import Optional, Dict, Any
5 | from .api import meta_api_tool, make_api_request
6 | from .server import mcp_server
7 |
8 |
9 | @mcp_server.tool()
10 | @meta_api_tool
11 | async def get_ad_accounts(access_token: Optional[str] = None, user_id: str = "me", limit: int = 200) -> str:
12 | """
13 | Get ad accounts accessible by a user.
14 |
15 | Args:
16 | access_token: Meta API access token (optional - will use cached token if not provided)
17 | user_id: Meta user ID or "me" for the current user
18 | limit: Maximum number of accounts to return (default: 200)
19 | """
20 | endpoint = f"{user_id}/adaccounts"
21 | params = {
22 | "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
23 | "limit": limit
24 | }
25 |
26 | data = await make_api_request(endpoint, access_token, params)
27 |
28 | return json.dumps(data, indent=2)
29 |
30 |
31 | @mcp_server.tool()
32 | @meta_api_tool
33 | async def get_account_info(account_id: str, access_token: Optional[str] = None) -> str:
34 | """
35 | Get detailed information about a specific ad account.
36 |
37 | Args:
38 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
39 | access_token: Meta API access token (optional - will use cached token if not provided)
40 | """
41 | if not account_id:
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 | }
49 |
50 | # Ensure account_id has the 'act_' prefix for API compatibility
51 | if not account_id.startswith("act_"):
52 | account_id = f"act_{account_id}"
53 |
54 | # Try to get the account info directly first
55 | endpoint = f"{account_id}"
56 | params = {
57 | "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
58 | }
59 |
60 | data = await make_api_request(endpoint, access_token, params)
61 |
62 | # Check if the API request returned an error
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
90 | return data
91 |
92 | # Add DSA requirement detection
93 | if "business_country_code" in data:
94 | european_countries = ["DE", "FR", "IT", "ES", "NL", "BE", "AT", "IE", "DK", "SE", "FI", "NO"]
95 | if data["business_country_code"] in european_countries:
96 | data["dsa_required"] = True
97 | data["dsa_compliance_note"] = "This account is subject to European DSA (Digital Services Act) requirements"
98 | else:
99 | data["dsa_required"] = False
100 | data["dsa_compliance_note"] = "This account is not subject to European DSA requirements"
101 |
102 | return data
```
--------------------------------------------------------------------------------
/tests/test_upload_ad_image.py:
--------------------------------------------------------------------------------
```python
1 | import json
2 | from unittest.mock import AsyncMock, patch
3 |
4 | import pytest
5 |
6 | from meta_ads_mcp.core.ads import upload_ad_image
7 |
8 |
9 | @pytest.mark.asyncio
10 | async def test_upload_ad_image_normalizes_images_dict():
11 | mock_response = {
12 | "images": {
13 | "abc123": {
14 | "hash": "abc123",
15 | "url": "https://example.com/image.jpg",
16 | "width": 1200,
17 | "height": 628,
18 | "name": "image.jpg",
19 | "status": 1,
20 | }
21 | }
22 | }
23 |
24 | with patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
25 | mock_api.return_value = mock_response
26 |
27 | # Use a data URL input to exercise that branch
28 | file_data_url = "data:image/png;base64,QUJDREVGRw==" # 'ABCDEFG' base64
29 | result_json = await upload_ad_image(
30 | access_token="test",
31 | account_id="act_123",
32 | file=file_data_url,
33 | name="my-upload.png",
34 | )
35 |
36 | result = json.loads(result_json)
37 |
38 | assert result.get("success") is True
39 | assert result.get("account_id") == "act_123"
40 | assert result.get("name") == "my-upload.png"
41 | assert result.get("image_hash") == "abc123"
42 | assert result.get("images_count") == 1
43 | assert isinstance(result.get("images"), list) and result["images"][0]["hash"] == "abc123"
44 |
45 |
46 | @pytest.mark.asyncio
47 | async def test_upload_ad_image_error_structure_is_surfaced():
48 | mock_response = {"error": {"message": "Something went wrong", "code": 400}}
49 |
50 | with patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
51 | mock_api.return_value = mock_response
52 |
53 | result_json = await upload_ad_image(
54 | access_token="test",
55 | account_id="act_123",
56 | file="data:image/png;base64,QUJD",
57 | )
58 |
59 | # Error responses from MCP functions may be wrapped multiple times under a data field
60 | payload = result_json
61 | for _ in range(5):
62 | # If it's a JSON string, parse it
63 | if isinstance(payload, str):
64 | try:
65 | payload = json.loads(payload)
66 | continue
67 | except Exception:
68 | break
69 | # If it's a dict containing a JSON string in data, unwrap once
70 | if isinstance(payload, dict) and "data" in payload:
71 | payload = payload["data"]
72 | continue
73 | break
74 | error_payload = payload if isinstance(payload, dict) else json.loads(payload)
75 |
76 | assert "error" in error_payload
77 | assert error_payload["error"] == "Failed to upload image"
78 | assert isinstance(error_payload.get("details"), dict)
79 | assert error_payload.get("account_id") == "act_123"
80 |
81 |
82 | @pytest.mark.asyncio
83 | async def test_upload_ad_image_fallback_wraps_raw_response():
84 | mock_response = {"unexpected": "shape"}
85 |
86 | with patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
87 | mock_api.return_value = mock_response
88 |
89 | result_json = await upload_ad_image(
90 | access_token="test",
91 | account_id="act_123",
92 | file="data:image/png;base64,QUJD",
93 | )
94 |
95 | result = json.loads(result_json)
96 | assert result.get("success") is True
97 | assert result.get("raw_response") == mock_response
98 |
99 |
100 | @pytest.mark.asyncio
101 | async def test_upload_ad_image_from_url_infers_name_and_prefixes_account_id():
102 | mock_response = {
103 | "images": {
104 | "hash999": {
105 | "url": "https://example.com/img.jpg",
106 | "width": 800,
107 | "height": 600,
108 | # omit nested hash intentionally to test normalization fallback
109 | }
110 | }
111 | }
112 |
113 | with patch("meta_ads_mcp.core.ads.try_multiple_download_methods", new_callable=AsyncMock) as mock_dl, \
114 | patch("meta_ads_mcp.core.ads.make_api_request", new_callable=AsyncMock) as mock_api:
115 | mock_dl.return_value = b"\xff\xd8\xff" # minimal JPEG header bytes
116 | mock_api.return_value = mock_response
117 |
118 | # Provide raw account id (without act_) and ensure it is prefixed in the result
119 | result_json = await upload_ad_image(
120 | access_token="test",
121 | account_id="701351919139047",
122 | image_url="https://cdn.example.com/path/photo.jpg?x=1",
123 | )
124 |
125 | result = json.loads(result_json)
126 | assert result.get("success") is True
127 | assert result.get("account_id") == "act_701351919139047"
128 | # Name should be inferred from URL
129 | assert result.get("name") == "photo.jpg"
130 | # Primary hash should be derived from key when nested hash missing
131 | assert result.get("image_hash") == "hash999"
132 | assert result.get("images_count") == 1
133 |
134 |
135 |
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/insights.py:
--------------------------------------------------------------------------------
```python
1 | """Insights and Reporting functionality for Meta Ads API."""
2 |
3 | import json
4 | from typing import Optional, Union, Dict
5 | from .api import meta_api_tool, make_api_request
6 | from .utils import download_image, try_multiple_download_methods, ad_creative_images, create_resource_from_image
7 | from .server import mcp_server
8 | import base64
9 | import datetime
10 |
11 |
12 | @mcp_server.tool()
13 | @meta_api_tool
14 | async def get_insights(object_id: str, access_token: Optional[str] = None,
15 | time_range: Union[str, Dict[str, str]] = "maximum", breakdown: str = "",
16 | level: str = "ad", limit: int = 25, after: str = "") -> str:
17 | """
18 | Get performance insights for a campaign, ad set, ad or account.
19 |
20 | Args:
21 | object_id: ID of the campaign, ad set, ad or account
22 | access_token: Meta API access token (optional - will use cached token if not provided)
23 | time_range: Either a preset time range string or a dictionary with "since" and "until" dates in YYYY-MM-DD format
24 | Preset options: today, yesterday, this_month, last_month, this_quarter, maximum, data_maximum,
25 | last_3d, last_7d, last_14d, last_28d, last_30d, last_90d, last_week_mon_sun,
26 | last_week_sun_sat, last_quarter, last_year, this_week_mon_today, this_week_sun_today, this_year
27 | Dictionary example: {"since":"2023-01-01","until":"2023-01-31"}
28 | breakdown: Optional breakdown dimension. Valid values include:
29 | Demographic: age, gender, country, region, dma
30 | Platform/Device: device_platform, platform_position, publisher_platform, impression_device
31 | Creative Assets: ad_format_asset, body_asset, call_to_action_asset, description_asset,
32 | image_asset, link_url_asset, title_asset, video_asset, media_asset_url,
33 | media_creator, media_destination_url, media_format, media_origin_url,
34 | media_text_content, media_type, creative_relaxation_asset_type,
35 | flexible_format_asset_type, gen_ai_asset_type
36 | Campaign/Ad Attributes: breakdown_ad_objective, breakdown_reporting_ad_id, app_id, product_id
37 | Conversion Tracking: coarse_conversion_value, conversion_destination, standard_event_content_type,
38 | signal_source_bucket, is_conversion_id_modeled, fidelity_type, redownload
39 | Time-based: hourly_stats_aggregated_by_advertiser_time_zone,
40 | hourly_stats_aggregated_by_audience_time_zone, frequency_value
41 | Extensions/Landing: ad_extension_domain, ad_extension_url, landing_destination,
42 | mdsa_landing_destination
43 | Attribution: sot_attribution_model_type, sot_attribution_window, sot_channel,
44 | sot_event_type, sot_source
45 | Mobile/SKAN: skan_campaign_id, skan_conversion_id, skan_version, postback_sequence_index
46 | CRM/Business: crm_advertiser_l12_territory_ids, crm_advertiser_subvertical_id,
47 | crm_advertiser_vertical_id, crm_ult_advertiser_id, user_persona_id, user_persona_name
48 | Advanced: hsid, is_auto_advance, is_rendered_as_delayed_skip_ad, mmm, place_page_id,
49 | marketing_messages_btn_name, impression_view_time_advertiser_hour_v2, comscore_market,
50 | comscore_market_code
51 | level: Level of aggregation (ad, adset, campaign, account)
52 | limit: Maximum number of results to return per page (default: 25, Meta API allows much higher values)
53 | after: Pagination cursor to get the next set of results. Use the 'after' cursor from previous response's paging.next field.
54 | """
55 | if not object_id:
56 | return json.dumps({"error": "No object ID provided"}, indent=2)
57 |
58 | endpoint = f"{object_id}/insights"
59 | params = {
60 | "fields": "account_id,account_name,campaign_id,campaign_name,adset_id,adset_name,ad_id,ad_name,impressions,clicks,spend,cpc,cpm,ctr,reach,frequency,actions,action_values,conversions,unique_clicks,cost_per_action_type",
61 | "level": level,
62 | "limit": limit
63 | }
64 |
65 | # Handle time range based on type
66 | if isinstance(time_range, dict):
67 | # Use custom date range with since/until parameters
68 | if "since" in time_range and "until" in time_range:
69 | params["time_range"] = json.dumps(time_range)
70 | else:
71 | return json.dumps({"error": "Custom time_range must contain both 'since' and 'until' keys in YYYY-MM-DD format"}, indent=2)
72 | else:
73 | # Use preset date range
74 | params["date_preset"] = time_range
75 |
76 | if breakdown:
77 | params["breakdowns"] = breakdown
78 |
79 | if after:
80 | params["after"] = after
81 |
82 | data = await make_api_request(endpoint, access_token, params)
83 |
84 | return json.dumps(data, indent=2)
85 |
86 |
87 |
88 |
89 |
90 |
```
--------------------------------------------------------------------------------
/RELEASE.md:
--------------------------------------------------------------------------------
```markdown
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 three 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 | # Update server.json (both top-level and package versions)
24 | sed -i '' 's/"version": "0.7.7"/"version": "0.7.8"/g' server.json
25 | ```
26 |
27 | Or manually edit:
28 | - `pyproject.toml`: `version = "0.7.8"`
29 | - `meta_ads_mcp/__init__.py`: `__version__ = "0.7.8"`
30 | - `server.json`: set `"version": "0.7.8"` and package `"version": "0.7.8"`
31 |
32 | 2. **Commit and push version changes**:
33 | ```bash
34 | git add pyproject.toml meta_ads_mcp/__init__.py server.json
35 | git commit -m "Bump version to 0.7.8"
36 | git push origin main
37 | ```
38 |
39 | 3. **Create GitHub release** (triggers automatic PyPI publishing):
40 | ```bash
41 | # Use bash wrapper if gh has issues in Cursor
42 | bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
43 | ```
44 |
45 | 4. **Verify release** (optional):
46 | ```bash
47 | # Check GitHub release
48 | curl -s "https://api.github.com/repos/pipeboard-co/meta-ads-mcp/releases/latest" | grep -E '"tag_name"|"name"'
49 |
50 | # Check PyPI availability (wait 2-3 minutes)
51 | curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep -E '"version"|"0.7.8"'
52 | ```
53 |
54 | ## 📋 Detailed Release Process
55 |
56 | ### Version Management Best Practices
57 |
58 | - **Semantic Versioning**: Follow `MAJOR.MINOR.PATCH` (e.g., 0.7.8)
59 | - **Synchronized Files**: Always update BOTH version files
60 | - **Commit Convention**: Use `"Bump version to X.Y.Z"` format
61 | - **Release Tag**: GitHub release tag matches version (no "v" prefix)
62 |
63 | ### Pre-Release Checklist
64 |
65 | ```bash
66 | # 1. Ensure clean working directory
67 | git status
68 |
69 | # 2. Run tests locally (optional but recommended)
70 | uv run python -m pytest tests/ -v
71 |
72 | # 3. Check current version
73 | grep -E 'version =|__version__|"version":' pyproject.toml meta_ads_mcp/__init__.py server.json
74 | ```
75 |
76 | ### Release Commands (One-liner)
77 |
78 | ```bash
79 | # Complete release in one sequence
80 | VERSION="0.7.8" && \
81 | sed -i '' "s/version = \"0.7.7\"/version = \"$VERSION\"/" pyproject.toml && \
82 | sed -i '' "s/__version__ = \"0.7.7\"/__version__ = \"$VERSION\"/" meta_ads_mcp/__init__.py && \
83 | sed -i '' "s/\"version\": \"0.7.7\"/\"version\": \"$VERSION\"/g" server.json && \
84 | git add pyproject.toml meta_ads_mcp/__init__.py server.json && \
85 | git commit -m "Bump version to $VERSION" && \
86 | git push origin main && \
87 | bash -c "gh release create $VERSION --title '$VERSION' --generate-notes"
88 | ```
89 |
90 | ## 🔄 Workflows
91 |
92 | ### `publish.yml` (Automatic)
93 | - **Trigger**: GitHub release creation
94 | - **Purpose**: Build and publish to PyPI
95 | - **Security**: OIDC tokens (no API keys)
96 | - **Status**: ✅ Fully automated
97 |
98 | ### `test.yml` (Validation)
99 | - **Trigger**: Push to main/master
100 | - **Purpose**: Package structure validation
101 | - **Matrix**: Python 3.10, 3.11, 3.12
102 | - **Note**: Build tests only, not pytest
103 |
104 | ## 🛠️ Troubleshooting
105 |
106 | ### Common Issues
107 |
108 | 1. **gh command issues in Cursor**:
109 | ```bash
110 | # Use bash wrapper
111 | bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
112 | ```
113 |
114 | 2. **Version mismatch**:
115 | ```bash
116 | # Verify all three files have the same version
117 | grep -E 'version =|__version__|"version":' pyproject.toml meta_ads_mcp/__init__.py server.json
118 | ```
119 |
120 | 3. **PyPI not updated**:
121 | ```bash
122 | # Check if package is available (wait 2-3 minutes)
123 | curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep '"version"'
124 | ```
125 |
126 | ### Manual Deployment (Fallback)
127 |
128 | ```bash
129 | # Install build tools
130 | pip install build twine
131 |
132 | # Build package
133 | python -m build
134 |
135 | # Upload to PyPI (requires API token)
136 | python -m twine upload dist/*
137 | ```
138 |
139 | ## 📊 Release Verification
140 |
141 | ### GitHub Release
142 | - ✅ Release created with correct tag
143 | - ✅ Auto-generated notes from commits
144 | - ✅ Actions tab shows successful workflow
145 |
146 | ### PyPI Package
147 | - ✅ Package available for installation
148 | - ✅ Correct version displayed
149 | - ✅ All dependencies listed
150 |
151 | ### Installation Test
152 | ```bash
153 | # Test new version installation
154 | pip install meta-ads-mcp==0.7.8
155 | # or
156 | uvx [email protected]
157 | ```
158 |
159 | ## 🔒 Security Notes
160 |
161 | - **Trusted Publishing**: Uses GitHub OIDC tokens (no API keys needed)
162 | - **Isolated Builds**: All builds run in GitHub-hosted runners
163 | - **Access Control**: Only maintainers can create releases
164 | - **Audit Trail**: All releases tracked in GitHub Actions
165 |
166 | ## 📈 Release Metrics
167 |
168 | Track successful releases:
169 | - **GitHub Releases**: https://github.com/pipeboard-co/meta-ads-mcp/releases
170 | - **PyPI Package**: https://pypi.org/project/meta-ads-mcp/
171 | - **Actions History**: https://github.com/pipeboard-co/meta-ads-mcp/actions
```
--------------------------------------------------------------------------------
/tests/test_create_ad_creative_simple.py:
--------------------------------------------------------------------------------
```python
1 | """Test that create_ad_creative handles simple creatives correctly."""
2 |
3 | import pytest
4 | import json
5 | from unittest.mock import AsyncMock, patch
6 | from meta_ads_mcp.core.ads import create_ad_creative
7 |
8 |
9 | @pytest.mark.asyncio
10 | async def test_simple_creative_uses_object_story_spec():
11 | """Test that singular headline/description uses object_story_spec, not asset_feed_spec."""
12 |
13 | # Mock the make_api_request function
14 | with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api, \
15 | patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
16 |
17 | # Mock page discovery
18 | mock_discover.return_value = {
19 | "success": True,
20 | "page_id": "123456789",
21 | "page_name": "Test Page"
22 | }
23 |
24 | # Mock creative creation response
25 | mock_api.side_effect = [
26 | # First call: Create creative
27 | {"id": "creative_123"},
28 | # Second call: Get creative details
29 | {
30 | "id": "creative_123",
31 | "name": "Test Creative",
32 | "status": "ACTIVE"
33 | }
34 | ]
35 |
36 | # Call create_ad_creative with singular headline and description
37 | result = await create_ad_creative(
38 | account_id="act_701351919139047",
39 | image_hash="test_hash_123",
40 | name="Math Problem - Hormozi",
41 | link_url="https://adrocketx.ai/",
42 | message="If you're spending 4+ hours per campaign...",
43 | headline="Stop paying yourself $12.50/hour",
44 | description="AI builds campaigns in 3min. 156% higher conversions. Free beta.",
45 | call_to_action_type="LEARN_MORE",
46 | access_token="test_token"
47 | )
48 |
49 | # Check that make_api_request was called
50 | assert mock_api.call_count == 2
51 |
52 | # Get the creative_data that was sent to the API
53 | create_call_args = mock_api.call_args_list[0]
54 | endpoint = create_call_args[0][0]
55 | creative_data = create_call_args[0][2]
56 |
57 | print("Creative data sent to API:")
58 | print(json.dumps(creative_data, indent=2))
59 |
60 | # Verify it uses object_story_spec, NOT asset_feed_spec
61 | assert "object_story_spec" in creative_data, "Should use object_story_spec for simple creatives"
62 | assert "asset_feed_spec" not in creative_data, "Should NOT use asset_feed_spec for simple creatives"
63 |
64 | # Verify object_story_spec structure
65 | assert "link_data" in creative_data["object_story_spec"]
66 | link_data = creative_data["object_story_spec"]["link_data"]
67 |
68 | # Verify simple creative fields are in link_data
69 | assert link_data["image_hash"] == "test_hash_123"
70 | assert link_data["link"] == "https://adrocketx.ai/"
71 | assert link_data["message"] == "If you're spending 4+ hours per campaign..."
72 |
73 | # The issue: headline and description should be in link_data for simple creatives
74 | # Not in asset_feed_spec
75 | print("\nlink_data structure:")
76 | print(json.dumps(link_data, indent=2))
77 |
78 |
79 | @pytest.mark.asyncio
80 | async def test_dynamic_creative_uses_asset_feed_spec():
81 | """Test that plural headlines/descriptions uses asset_feed_spec."""
82 |
83 | with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api, \
84 | patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
85 |
86 | # Mock page discovery
87 | mock_discover.return_value = {
88 | "success": True,
89 | "page_id": "123456789",
90 | "page_name": "Test Page"
91 | }
92 |
93 | # Mock creative creation response
94 | mock_api.side_effect = [
95 | {"id": "creative_456"},
96 | {"id": "creative_456", "name": "Dynamic Creative", "status": "ACTIVE"}
97 | ]
98 |
99 | # Call with PLURAL headlines and descriptions (dynamic creative)
100 | result = await create_ad_creative(
101 | account_id="act_701351919139047",
102 | image_hash="test_hash_456",
103 | name="Dynamic Creative Test",
104 | link_url="https://example.com/",
105 | message="Test message",
106 | headlines=["Headline 1", "Headline 2"],
107 | descriptions=["Description 1", "Description 2"],
108 | access_token="test_token"
109 | )
110 |
111 | # Get the creative_data that was sent to the API
112 | create_call_args = mock_api.call_args_list[0]
113 | creative_data = create_call_args[0][2]
114 |
115 | print("\nDynamic creative data sent to API:")
116 | print(json.dumps(creative_data, indent=2))
117 |
118 | # Verify it uses asset_feed_spec for dynamic creatives
119 | assert "asset_feed_spec" in creative_data, "Should use asset_feed_spec for dynamic creatives"
120 |
121 | # Verify asset_feed_spec structure
122 | asset_feed_spec = creative_data["asset_feed_spec"]
123 | assert "headlines" in asset_feed_spec
124 | assert len(asset_feed_spec["headlines"]) == 2
125 | assert "descriptions" in asset_feed_spec
126 | assert len(asset_feed_spec["descriptions"]) == 2
127 |
128 |
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/reports.py:
--------------------------------------------------------------------------------
```python
1 | """Report generation functionality for Meta Ads API."""
2 |
3 | import json
4 | import os
5 | from typing import Optional, Dict, Any, List, Union
6 | from .api import meta_api_tool
7 | from .server import mcp_server
8 |
9 |
10 | # Only register the generate_report function if the environment variable is set
11 | ENABLE_REPORT_GENERATION = bool(os.environ.get("META_ADS_ENABLE_REPORTS", ""))
12 |
13 | if ENABLE_REPORT_GENERATION:
14 | @mcp_server.tool()
15 | async def generate_report(
16 | account_id: str,
17 | access_token: Optional[str] = None,
18 | report_type: str = "account",
19 | time_range: str = "last_30d",
20 | campaign_ids: Optional[List[str]] = None,
21 | export_format: str = "pdf",
22 | report_name: Optional[str] = None,
23 | include_sections: Optional[List[str]] = None,
24 | breakdowns: Optional[List[str]] = None,
25 | comparison_period: Optional[str] = None
26 | ) -> str:
27 | """
28 | Generate comprehensive Meta Ads performance reports.
29 |
30 | **This is a premium feature available with Pipeboard Pro.**
31 |
32 | Args:
33 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
34 | access_token: Meta API access token (optional - will use cached token if not provided)
35 | report_type: Type of report to generate (account, campaign, comparison)
36 | time_range: Time period for the report (e.g., 'last_30d', 'last_7d', 'this_month')
37 | campaign_ids: Specific campaign IDs (required for campaign/comparison reports)
38 | export_format: Output format for the report (pdf, json, html)
39 | report_name: Custom name for the report (auto-generated if not provided)
40 | include_sections: Specific sections to include in the report
41 | breakdowns: Audience breakdown dimensions (age, gender, country, etc.)
42 | comparison_period: Time period for comparison analysis
43 | """
44 |
45 | # Validate required parameters
46 | if not account_id:
47 | return json.dumps({
48 | "error": "invalid_parameters",
49 | "message": "Account ID is required",
50 | "details": {
51 | "required_parameter": "account_id",
52 | "format": "act_XXXXXXXXX"
53 | }
54 | }, indent=2)
55 |
56 | # For campaign and comparison reports, campaign_ids are required
57 | if report_type in ["campaign", "comparison"] and not campaign_ids:
58 | return json.dumps({
59 | "error": "invalid_parameters",
60 | "message": f"Campaign IDs are required for {report_type} reports",
61 | "details": {
62 | "required_parameter": "campaign_ids",
63 | "format": "Array of campaign ID strings"
64 | }
65 | }, indent=2)
66 |
67 | # Return premium feature upgrade message
68 | return json.dumps({
69 | "error": "premium_feature_required",
70 | "message": "Professional report generation is a premium feature",
71 | "details": {
72 | "feature": "Automated PDF Report Generation",
73 | "description": "Create professional client-ready reports with performance insights, recommendations, and white-label branding",
74 | "benefits": [
75 | "Executive summary with key metrics",
76 | "Performance breakdowns and trends",
77 | "Audience insights and recommendations",
78 | "Professional PDF formatting",
79 | "White-label branding options",
80 | "Campaign comparison analysis",
81 | "Creative performance insights",
82 | "Automated scheduling options"
83 | ],
84 | "upgrade_url": "https://pipeboard.co/upgrade",
85 | "contact_email": "[email protected]",
86 | "early_access": "Contact us for early access and special pricing"
87 | },
88 | "request_parameters": {
89 | "account_id": account_id,
90 | "report_type": report_type,
91 | "time_range": time_range,
92 | "export_format": export_format,
93 | "campaign_ids": campaign_ids or [],
94 | "include_sections": include_sections or [],
95 | "breakdowns": breakdowns or []
96 | },
97 | "preview": {
98 | "available_data": {
99 | "account_name": f"Account {account_id}",
100 | "campaigns_count": len(campaign_ids) if campaign_ids else "All campaigns",
101 | "time_range": time_range,
102 | "estimated_report_pages": 8 if report_type == "account" else 6,
103 | "report_format": export_format.upper()
104 | },
105 | "sample_metrics": {
106 | "total_spend": "$12,450",
107 | "total_impressions": "2.3M",
108 | "total_clicks": "45.2K",
109 | "average_cpc": "$0.85",
110 | "average_cpm": "$15.20",
111 | "click_through_rate": "1.96%",
112 | "roas": "4.2x"
113 | },
114 | "available_sections": [
115 | "executive_summary",
116 | "performance_overview",
117 | "campaign_breakdown",
118 | "audience_insights",
119 | "creative_performance",
120 | "recommendations",
121 | "appendix"
122 | ],
123 | "supported_breakdowns": [
124 | "age",
125 | "gender",
126 | "country",
127 | "region",
128 | "placement",
129 | "device_platform",
130 | "publisher_platform"
131 | ]
132 | }
133 | }, indent=2)
```
--------------------------------------------------------------------------------
/tests/README_REGRESSION_TESTS.md:
--------------------------------------------------------------------------------
```markdown
1 | # Duplication Module Regression Tests
2 |
3 | This document describes the comprehensive regression test suite for the Meta Ads duplication module (`meta_ads_mcp/core/duplication.py`).
4 |
5 | ## Test Coverage Overview
6 |
7 | The regression test suite (`test_duplication_regression.py`) contains **23 comprehensive tests** organized into 7 test classes, providing extensive coverage to prevent future regressions.
8 |
9 | ### 🎯 Test Classes
10 |
11 | #### 1. `TestDuplicationFeatureToggle` (4 tests)
12 | - **Purpose**: Ensures the feature toggle mechanism works correctly
13 | - **Coverage**:
14 | - Feature disabled by default
15 | - Feature enabled with environment variable
16 | - Various truthy values enable the feature
17 | - Empty string disables the feature
18 | - **Prevents**: Accidental feature activation, broken environment variable handling
19 |
20 | #### 2. `TestDuplicationDecorators` (2 tests)
21 | - **Purpose**: Validates that all decorators are applied correctly
22 | - **Coverage**:
23 | - `@meta_api_tool` decorator applied to all functions
24 | - `@mcp_server.tool()` decorator registers functions as MCP tools
25 | - **Prevents**: Functions missing required decorators, broken MCP registration
26 |
27 | #### 3. `TestDuplicationAPIContract` (3 tests)
28 | - **Purpose**: Ensures external API calls follow the correct contract
29 | - **Coverage**:
30 | - API endpoint URL construction
31 | - HTTP request headers format
32 | - Request timeout configuration
33 | - **Prevents**: Broken API integration, malformed requests
34 |
35 | #### 4. `TestDuplicationErrorHandling` (3 tests)
36 | - **Purpose**: Validates robust error handling across all scenarios
37 | - **Coverage**:
38 | - Missing access token errors
39 | - HTTP status code handling (200, 401, 403, 429, 500)
40 | - Network error handling (timeouts, connection failures)
41 | - **Prevents**: Unhandled errors, poor error messages, broken error paths
42 |
43 | #### 5. `TestDuplicationParameterHandling` (3 tests)
44 | - **Purpose**: Tests parameter processing and forwarding
45 | - **Coverage**:
46 | - None values filtered from options
47 | - Parameter forwarding accuracy
48 | - Estimated components calculation
49 | - **Prevents**: Malformed API requests, parameter corruption
50 |
51 | #### 6. `TestDuplicationIntegration` (2 tests)
52 | - **Purpose**: End-to-end functionality testing
53 | - **Coverage**:
54 | - Successful duplication flow
55 | - Premium feature upgrade flow
56 | - **Prevents**: Broken end-to-end flows, integration failures
57 |
58 | #### 7. `TestDuplicationTokenHandling` (2 tests)
59 | - **Purpose**: Access token management and injection
60 | - **Coverage**:
61 | - Explicit token handling
62 | - Token parameter override behavior
63 | - **Prevents**: Authentication bypasses, token handling bugs
64 |
65 | #### 8. `TestDuplicationRegressionEdgeCases` (4 tests)
66 | - **Purpose**: Edge cases and unusual scenarios
67 | - **Coverage**:
68 | - Empty string parameters
69 | - Unicode parameter handling
70 | - Large parameter values
71 | - Module reload safety
72 | - **Prevents**: Edge case failures, data corruption, memory leaks
73 |
74 | ## 🚀 Key Features Tested
75 |
76 | ### Authentication & Security
77 | - ✅ Access token validation and injection
78 | - ✅ Authentication error handling
79 | - ✅ App ID validation
80 | - ✅ Secure token forwarding
81 |
82 | ### API Integration
83 | - ✅ HTTP client configuration
84 | - ✅ Request/response handling
85 | - ✅ Error status code processing
86 | - ✅ Network failure resilience
87 |
88 | ### Feature Management
89 | - ✅ Environment-based feature toggle
90 | - ✅ Dynamic module loading
91 | - ✅ MCP tool registration
92 | - ✅ Decorator chain validation
93 |
94 | ### Data Processing
95 | - ✅ Parameter validation and filtering
96 | - ✅ Unicode and special character handling
97 | - ✅ Large value processing
98 | - ✅ JSON serialization/deserialization
99 |
100 | ### Error Resilience
101 | - ✅ Network timeouts and failures
102 | - ✅ Malformed responses
103 | - ✅ Authentication failures
104 | - ✅ Rate limiting scenarios
105 |
106 | ## 🛡️ Regression Prevention
107 |
108 | These tests specifically prevent the following categories of regressions:
109 |
110 | ### **Configuration Regressions**
111 | - Feature accidentally enabled/disabled
112 | - Environment variable handling changes
113 | - Default configuration drift
114 |
115 | ### **Integration Regressions**
116 | - API endpoint URL changes
117 | - Request format modifications
118 | - Authentication system changes
119 |
120 | ### **Error Handling Regressions**
121 | - Silent error failures
122 | - Poor error message quality
123 | - Unhandled exception scenarios
124 |
125 | ### **Performance Regressions**
126 | - Memory leaks in module reloading
127 | - Inefficient parameter processing
128 | - Network timeout misconfigurations
129 |
130 | ### **Security Regressions**
131 | - Token handling vulnerabilities
132 | - Authentication bypass bugs
133 | - Parameter injection attacks
134 |
135 | ## 🔧 Running the Tests
136 |
137 | ```bash
138 | # Run all regression tests
139 | python -m pytest tests/test_duplication_regression.py -v
140 |
141 | # Run specific test class
142 | python -m pytest tests/test_duplication_regression.py::TestDuplicationFeatureToggle -v
143 |
144 | # Run with coverage
145 | python -m pytest tests/test_duplication_regression.py --cov=meta_ads_mcp.core.duplication
146 |
147 | # Run with detailed output
148 | python -m pytest tests/test_duplication_regression.py -vvv --tb=long
149 | ```
150 |
151 | ## 📊 Test Results
152 |
153 | When all tests pass, you should see:
154 | ```
155 | ====================== 23 passed, 5 warnings in 0.54s ======================
156 | ```
157 |
158 | The warnings are from mock objects and don't affect functionality.
159 |
160 | ## 🔍 Test Design Principles
161 |
162 | 1. **Isolation**: Each test is independent and can run standalone
163 | 2. **Mocking**: External dependencies are mocked for reliability
164 | 3. **Comprehensive**: Cover both happy path and error scenarios
165 | 4. **Realistic**: Use realistic data and scenarios
166 | 5. **Maintainable**: Clear test names and documentation
167 |
168 | ## 🚨 Adding New Tests
169 |
170 | When adding new functionality to the duplication module:
171 |
172 | 1. **Add corresponding regression tests**
173 | 2. **Test both success and failure scenarios**
174 | 3. **Mock external dependencies appropriately**
175 | 4. **Use descriptive test names**
176 | 5. **Update this documentation**
177 |
178 | ## 📈 Coverage Goals
179 |
180 | - **Line Coverage**: > 95%
181 | - **Branch Coverage**: > 90%
182 | - **Function Coverage**: 100%
183 | - **Error Path Coverage**: > 85%
184 |
185 | This comprehensive test suite ensures the duplication module remains stable and reliable across future changes and updates.
```
--------------------------------------------------------------------------------
/tests/test_get_ad_creatives_fix.py:
--------------------------------------------------------------------------------
```python
1 | """Regression tests for get_ad_creatives function bug fix.
2 |
3 | Tests for issue where get_ad_creatives would throw:
4 | 'TypeError: 'dict' object is not callable'
5 |
6 | This was caused by trying to call ad_creative_images(creative) where
7 | ad_creative_images is a dictionary, not a function.
8 |
9 | The fix was to create extract_creative_image_urls() function and use that instead.
10 | """
11 |
12 | import pytest
13 | import json
14 | from unittest.mock import AsyncMock, patch
15 | from meta_ads_mcp.core.ads import get_ad_creatives
16 | from meta_ads_mcp.core.utils import ad_creative_images
17 |
18 |
19 | @pytest.mark.asyncio
20 | class TestGetAdCreativesBugFix:
21 | """Regression test cases for the get_ad_creatives function bug fix."""
22 |
23 | async def test_get_ad_creatives_regression_fix(self):
24 | """Regression test: ensure get_ad_creatives works correctly and doesn't throw 'dict' object is not callable."""
25 |
26 | # Mock the make_api_request to return sample creative data
27 | sample_creative_data = {
28 | "data": [
29 | {
30 | "id": "123456789",
31 | "name": "Test Creative",
32 | "status": "ACTIVE",
33 | "thumbnail_url": "https://example.com/thumb.jpg",
34 | "image_url": "https://example.com/image.jpg",
35 | "image_hash": "abc123",
36 | "object_story_spec": {
37 | "page_id": "987654321",
38 | "link_data": {
39 | "image_hash": "abc123",
40 | "link": "https://example.com",
41 | "name": "Test Ad"
42 | }
43 | }
44 | }
45 | ]
46 | }
47 |
48 | with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api:
49 | mock_api.return_value = sample_creative_data
50 |
51 | # This should NOT raise a TypeError anymore
52 | # Previously this would fail with: TypeError: 'dict' object is not callable
53 | result = await get_ad_creatives(access_token="test_token", ad_id="120228922933270272")
54 |
55 | # Parse the result
56 | result_data = json.loads(result)
57 |
58 | # Verify the structure is correct
59 | assert "data" in result_data
60 | assert len(result_data["data"]) == 1
61 |
62 | creative = result_data["data"][0]
63 | assert creative["id"] == "123456789"
64 | assert creative["name"] == "Test Creative"
65 | assert "image_urls_for_viewing" in creative
66 | assert isinstance(creative["image_urls_for_viewing"], list)
67 |
68 | async def test_extract_creative_image_urls_function(self):
69 | """Test the extract_creative_image_urls function works correctly."""
70 | from meta_ads_mcp.core.utils import extract_creative_image_urls
71 |
72 | # Test creative with various image URL fields
73 | test_creative = {
74 | "id": "123456789",
75 | "name": "Test Creative",
76 | "status": "ACTIVE",
77 | "thumbnail_url": "https://example.com/thumb.jpg",
78 | "image_url": "https://example.com/image.jpg",
79 | "image_hash": "abc123",
80 | "object_story_spec": {
81 | "page_id": "987654321",
82 | "link_data": {
83 | "image_hash": "abc123",
84 | "link": "https://example.com",
85 | "name": "Test Ad",
86 | "picture": "https://example.com/picture.jpg"
87 | }
88 | }
89 | }
90 |
91 | urls = extract_creative_image_urls(test_creative)
92 |
93 | # Should extract URLs in order: image_url, picture, thumbnail_url (new priority order)
94 | expected_urls = [
95 | "https://example.com/image.jpg",
96 | "https://example.com/picture.jpg",
97 | "https://example.com/thumb.jpg"
98 | ]
99 |
100 | assert urls == expected_urls
101 |
102 | # Test with empty creative
103 | empty_urls = extract_creative_image_urls({})
104 | assert empty_urls == []
105 |
106 | # Test with duplicates (should remove them)
107 | duplicate_creative = {
108 | "image_url": "https://example.com/same.jpg",
109 | "thumbnail_url": "https://example.com/same.jpg", # Same URL
110 | }
111 | unique_urls = extract_creative_image_urls(duplicate_creative)
112 | assert unique_urls == ["https://example.com/same.jpg"]
113 |
114 | async def test_get_ad_creatives_no_ad_id(self):
115 | """Test get_ad_creatives with no ad_id provided."""
116 |
117 | result = await get_ad_creatives(access_token="test_token", ad_id=None)
118 | result_data = json.loads(result)
119 |
120 | # The @meta_api_tool decorator wraps the result in a data field
121 | assert "data" in result_data
122 | error_data = json.loads(result_data["data"])
123 | assert "error" in error_data
124 | assert error_data["error"] == "No ad ID provided"
125 |
126 | async def test_get_ad_creatives_empty_data(self):
127 | """Test get_ad_creatives when API returns empty data."""
128 |
129 | empty_data = {"data": []}
130 |
131 | with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api:
132 | mock_api.return_value = empty_data
133 |
134 | result = await get_ad_creatives(access_token="test_token", ad_id="120228922933270272")
135 | result_data = json.loads(result)
136 |
137 | assert "data" in result_data
138 | assert len(result_data["data"]) == 0
139 |
140 |
141 | def test_ad_creative_images_is_dict():
142 | """Test that ad_creative_images is a dictionary, not a function.
143 |
144 | This confirms the original issue: ad_creative_images is a dict for storing image data,
145 | but was being called as a function ad_creative_images(creative), which would fail.
146 | This test ensures we don't accidentally change ad_creative_images to a function
147 | and break its intended purpose as a storage dictionary.
148 | """
149 | assert isinstance(ad_creative_images, dict)
150 |
151 | # This would raise TypeError: 'dict' object is not callable
152 | # This is the original bug - trying to call a dict as a function
153 | with pytest.raises(TypeError, match="'dict' object is not callable"):
154 | ad_creative_images({"test": "data"})
```
--------------------------------------------------------------------------------
/examples/example_http_client.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Example HTTP client for Meta Ads MCP Streamable HTTP transport
4 |
5 | This demonstrates how to use the completed HTTP transport implementation
6 | to access Meta Ads tools via HTTP API calls.
7 |
8 | Usage:
9 | 1. Start the server: python -m meta_ads_mcp --transport streamable-http
10 | 2. Run this example: python example_http_client.py
11 | """
12 |
13 | import requests
14 | import json
15 | import os
16 | from typing import Dict, Any, Optional
17 |
18 | class MetaAdsMCPClient:
19 | """Simple HTTP client for Meta Ads MCP server"""
20 |
21 | def __init__(self, base_url: str = "http://localhost:8080",
22 | pipeboard_token: Optional[str] = None,
23 | meta_access_token: Optional[str] = None):
24 | """Initialize the client
25 |
26 | Args:
27 | base_url: Base URL of the MCP server
28 | pipeboard_token: Pipeboard API token (recommended)
29 | meta_access_token: Direct Meta access token (fallback)
30 | """
31 | self.base_url = base_url.rstrip('/')
32 | self.endpoint = f"{self.base_url}/mcp/"
33 | self.session_id = 1
34 |
35 | # Setup authentication headers
36 | self.headers = {
37 | "Content-Type": "application/json",
38 | "Accept": "application/json, text/event-stream",
39 | "User-Agent": "MetaAdsMCP-Example-Client/1.0"
40 | }
41 |
42 | # Add authentication
43 | if pipeboard_token:
44 | self.headers["Authorization"] = f"Bearer {pipeboard_token}"
45 | print(f"✅ Using Pipeboard authentication")
46 | elif meta_access_token:
47 | self.headers["X-META-ACCESS-TOKEN"] = meta_access_token
48 | print(f"✅ Using direct Meta token authentication")
49 | else:
50 | print(f"⚠️ No authentication provided - tools will require auth")
51 |
52 | def _make_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
53 | """Make a JSON-RPC request to the server"""
54 | payload = {
55 | "jsonrpc": "2.0",
56 | "method": method,
57 | "id": self.session_id
58 | }
59 |
60 | if params:
61 | payload["params"] = params
62 |
63 | print(f"\n🔄 Making request: {method}")
64 | print(f" URL: {self.endpoint}")
65 | print(f" Headers: {json.dumps(dict(self.headers), indent=2)}")
66 | print(f" Payload: {json.dumps(payload, indent=2)}")
67 |
68 | try:
69 | response = requests.post(
70 | self.endpoint,
71 | headers=self.headers,
72 | json=payload,
73 | timeout=30
74 | )
75 |
76 | print(f" Status: {response.status_code} {response.reason}")
77 | print(f" Response Headers: {dict(response.headers)}")
78 |
79 | if response.status_code == 200:
80 | result = response.json()
81 | print(f"✅ Request successful")
82 | return result
83 | else:
84 | print(f"❌ Request failed: {response.status_code}")
85 | print(f" Response: {response.text}")
86 | return {"error": {"code": response.status_code, "message": response.text}}
87 |
88 | except Exception as e:
89 | print(f"❌ Request exception: {e}")
90 | return {"error": {"code": -1, "message": str(e)}}
91 | finally:
92 | self.session_id += 1
93 |
94 | def initialize(self) -> Dict[str, Any]:
95 | """Initialize MCP session"""
96 | return self._make_request("initialize", {
97 | "protocolVersion": "2024-11-05",
98 | "capabilities": {
99 | "roots": {"listChanged": True},
100 | "sampling": {}
101 | },
102 | "clientInfo": {
103 | "name": "meta-ads-example-client",
104 | "version": "1.0.0"
105 | }
106 | })
107 |
108 | def list_tools(self) -> Dict[str, Any]:
109 | """Get list of available tools"""
110 | return self._make_request("tools/list")
111 |
112 | def call_tool(self, tool_name: str, arguments: Dict[str, Any] = None) -> Dict[str, Any]:
113 | """Call a specific tool"""
114 | params = {"name": tool_name}
115 | if arguments:
116 | params["arguments"] = arguments
117 |
118 | return self._make_request("tools/call", params)
119 |
120 | def main():
121 | """Example usage of the Meta Ads MCP HTTP client"""
122 | print("🚀 Meta Ads MCP HTTP Client Example")
123 | print("="*60)
124 |
125 | # Check for authentication
126 | pipeboard_token = os.environ.get("PIPEBOARD_API_TOKEN")
127 | meta_token = os.environ.get("META_ACCESS_TOKEN")
128 |
129 | if not pipeboard_token and not meta_token:
130 | print("⚠️ No authentication tokens found in environment")
131 | print(" Set PIPEBOARD_API_TOKEN or META_ACCESS_TOKEN for full functionality")
132 | print(" Using test token for demonstration...")
133 | pipeboard_token = "demo_token_12345"
134 |
135 | # Create client
136 | client = MetaAdsMCPClient(
137 | pipeboard_token=pipeboard_token,
138 | meta_access_token=meta_token
139 | )
140 |
141 | # Test the MCP protocol flow
142 | print("\n🔄 Testing MCP Protocol Flow")
143 | print("="*50)
144 |
145 | # 1. Initialize
146 | print("\n" + "="*60)
147 | print("🔍 Step 1: Initialize MCP Session")
148 | print("="*60)
149 | init_result = client.initialize()
150 |
151 | if "error" in init_result:
152 | print(f"❌ Initialize failed: {init_result['error']}")
153 | return
154 |
155 | print(f"✅ Initialize successful")
156 | print(f" Server info: {init_result['result']['serverInfo']}")
157 | print(f" Protocol version: {init_result['result']['protocolVersion']}")
158 |
159 | # 2. List tools
160 | print("\n" + "="*60)
161 | print("🔍 Step 2: List Available Tools")
162 | print("="*60)
163 | tools_result = client.list_tools()
164 |
165 | if "error" in tools_result:
166 | print(f"❌ Tools list failed: {tools_result['error']}")
167 | return
168 |
169 | tools = tools_result["result"]["tools"]
170 | print(f"✅ Found {len(tools)} tools:")
171 |
172 | # Show first few tools
173 | for i, tool in enumerate(tools[:5]):
174 | print(f" {i+1}. {tool['name']}: {tool['description'][:100]}...")
175 |
176 | if len(tools) > 5:
177 | print(f" ... and {len(tools) - 5} more tools")
178 |
179 | # 3. Test a simple tool call
180 | print("\n" + "="*60)
181 | print("🔍 Step 3: Test Tool Call - get_ad_accounts")
182 | print("="*60)
183 |
184 | tool_result = client.call_tool("get_ad_accounts", {"limit": 3})
185 |
186 | if "error" in tool_result:
187 | print(f"❌ Tool call failed: {tool_result['error']}")
188 | return
189 |
190 | print(f"✅ Tool call successful")
191 | content = tool_result["result"]["content"][0]["text"]
192 |
193 | # Parse the response to see if it's authentication or actual data
194 | try:
195 | parsed_content = json.loads(content)
196 | if "error" in parsed_content and "Authentication Required" in parsed_content["error"]["message"]:
197 | print(f"📋 Result: Authentication required (expected with demo token)")
198 | print(f" This confirms the HTTP transport is working!")
199 | print(f" Use a real Pipeboard token for actual data access.")
200 | else:
201 | print(f"📋 Result: {content[:200]}...")
202 | except:
203 | print(f"📋 Raw result: {content[:200]}...")
204 |
205 | # Summary
206 | print("\n" + "🎯" * 30)
207 | print("EXAMPLE COMPLETE")
208 | print("🎯" * 30)
209 | print("\n📊 Results:")
210 | print(" Initialize: ✅ SUCCESS")
211 | print(" Tools List: ✅ SUCCESS")
212 | print(" Tool Call: ✅ SUCCESS")
213 | print("\n🎉 Meta Ads MCP HTTP transport is fully functional!")
214 | print("\n💡 Next steps:")
215 | print(" 1. Set PIPEBOARD_API_TOKEN environment variable")
216 | print(" 2. Call any of the 26 available Meta Ads tools")
217 | print(" 3. Build your web application or automation scripts")
218 |
219 | if __name__ == "__main__":
220 | main()
```
--------------------------------------------------------------------------------
/tests/test_integration_openai_mcp.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Integration Test for OpenAI MCP functionality with existing Meta Ads tools
4 |
5 | This test verifies that:
6 | 1. Existing Meta Ads tools still work after adding OpenAI MCP tools
7 | 2. New search and fetch tools are properly registered
8 | 3. Both old and new tools can coexist without conflicts
9 |
10 | Usage:
11 | python tests/test_integration_openai_mcp.py
12 | """
13 |
14 | import sys
15 | import os
16 | import importlib
17 |
18 | # Add project root to path for imports
19 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20 |
21 | def test_module_imports():
22 | """Test that all modules can be imported successfully"""
23 | print("🧪 Testing module imports...")
24 |
25 | try:
26 | # Test core module import
27 | import meta_ads_mcp.core as core
28 | print("✅ Core module imported successfully")
29 |
30 | # Test that mcp_server is available
31 | assert hasattr(core, 'mcp_server'), "mcp_server not found in core module"
32 | print("✅ mcp_server available")
33 |
34 | # Test that existing tools are still available
35 | existing_tools = [
36 | 'get_ad_accounts', 'get_account_info', 'get_campaigns',
37 | 'get_ads', 'get_insights', 'search_ads_archive'
38 | ]
39 |
40 | for tool in existing_tools:
41 | assert hasattr(core, tool), f"Existing tool {tool} not found"
42 | print(f"✅ All {len(existing_tools)} existing tools available")
43 |
44 | # Test that new OpenAI tools are available
45 | openai_tools = ['search', 'fetch']
46 | for tool in openai_tools:
47 | assert hasattr(core, tool), f"OpenAI tool {tool} not found"
48 | print(f"✅ All {len(openai_tools)} OpenAI MCP tools available")
49 |
50 | return True
51 |
52 | except Exception as e:
53 | print(f"❌ Module import test failed: {e}")
54 | return False
55 |
56 | def test_tool_registration():
57 | """Test that tools are properly registered with the MCP server"""
58 | print("\n🧪 Testing tool registration...")
59 |
60 | try:
61 | # Import the server and get registered tools
62 | from meta_ads_mcp.core.server import mcp_server
63 |
64 | # Get all registered tools
65 | # Note: FastMCP may not expose tools until runtime, so we'll check the module structure
66 | print("✅ MCP server accessible")
67 |
68 | # Test that OpenAI Deep Research module can be imported
69 | from meta_ads_mcp.core import openai_deep_research
70 | print("✅ OpenAI Deep Research module imported")
71 |
72 | # Test that the tools are callable
73 | assert callable(openai_deep_research.search), "search tool is not callable"
74 | assert callable(openai_deep_research.fetch), "fetch tool is not callable"
75 | print("✅ OpenAI tools are callable")
76 |
77 | return True
78 |
79 | except Exception as e:
80 | print(f"❌ Tool registration test failed: {e}")
81 | return False
82 |
83 | def test_tool_signatures():
84 | """Test that tool signatures are correct"""
85 | print("\n🧪 Testing tool signatures...")
86 |
87 | try:
88 | from meta_ads_mcp.core.openai_deep_research import search, fetch
89 | import inspect
90 |
91 | # Test search tool signature
92 | search_sig = inspect.signature(search)
93 | search_params = list(search_sig.parameters.keys())
94 |
95 | # Should have access_token and query parameters
96 | expected_search_params = ['access_token', 'query']
97 | for param in expected_search_params:
98 | assert param in search_params, f"search tool missing parameter: {param}"
99 | print("✅ search tool has correct signature")
100 |
101 | # Test fetch tool signature
102 | fetch_sig = inspect.signature(fetch)
103 | fetch_params = list(fetch_sig.parameters.keys())
104 |
105 | # Should have id parameter
106 | assert 'id' in fetch_params, "fetch tool missing 'id' parameter"
107 | print("✅ fetch tool has correct signature")
108 |
109 | return True
110 |
111 | except Exception as e:
112 | print(f"❌ Tool signature test failed: {e}")
113 | return False
114 |
115 | def test_existing_functionality_preserved():
116 | """Test that existing functionality is not broken"""
117 | print("\n🧪 Testing existing functionality preservation...")
118 |
119 | try:
120 | # Test that we can still import and access existing tools
121 | from meta_ads_mcp.core.accounts import get_ad_accounts
122 | from meta_ads_mcp.core.campaigns import get_campaigns
123 | from meta_ads_mcp.core.ads import get_ads
124 | from meta_ads_mcp.core.insights import get_insights
125 |
126 | # Verify they're still callable
127 | assert callable(get_ad_accounts), "get_ad_accounts is not callable"
128 | assert callable(get_campaigns), "get_campaigns is not callable"
129 | assert callable(get_ads), "get_ads is not callable"
130 | assert callable(get_insights), "get_insights is not callable"
131 |
132 | print("✅ All existing tools remain callable")
133 |
134 | # Test that existing tool signatures haven't changed
135 | import inspect
136 |
137 | accounts_sig = inspect.signature(get_ad_accounts)
138 | accounts_params = list(accounts_sig.parameters.keys())
139 | assert 'access_token' in accounts_params, "get_ad_accounts signature changed"
140 |
141 | print("✅ Existing tool signatures preserved")
142 |
143 | return True
144 |
145 | except Exception as e:
146 | print(f"❌ Existing functionality test failed: {e}")
147 | return False
148 |
149 | def test_no_name_conflicts():
150 | """Test that there are no naming conflicts between old and new tools"""
151 | print("\n🧪 Testing for naming conflicts...")
152 |
153 | try:
154 | import meta_ads_mcp.core as core
155 |
156 | # Get all attributes from the core module
157 | all_attrs = dir(core)
158 |
159 | # Check for expected tools
160 | existing_tools = [
161 | 'get_ad_accounts', 'get_campaigns', 'get_ads', 'get_insights',
162 | 'search_ads_archive' # This is the existing search function
163 | ]
164 |
165 | new_tools = ['search', 'fetch'] # These are the new OpenAI tools
166 |
167 | # Verify all tools exist
168 | for tool in existing_tools + new_tools:
169 | assert tool in all_attrs, f"Tool {tool} not found in core module"
170 |
171 | # Verify search_ads_archive and search are different functions
172 | assert core.search_ads_archive != core.search, "search and search_ads_archive should be different functions"
173 |
174 | print("✅ No naming conflicts detected")
175 | print(f" - Existing search tool: search_ads_archive (Meta Ads Library)")
176 | print(f" - New search tool: search (OpenAI MCP Deep Research)")
177 |
178 | return True
179 |
180 | except Exception as e:
181 | print(f"❌ Naming conflict test failed: {e}")
182 | return False
183 |
184 | def main():
185 | """Run all integration tests"""
186 | print("🚀 OpenAI MCP Integration Tests")
187 | print("="*50)
188 |
189 | tests = [
190 | ("Module Imports", test_module_imports),
191 | ("Tool Registration", test_tool_registration),
192 | ("Tool Signatures", test_tool_signatures),
193 | ("Existing Functionality", test_existing_functionality_preserved),
194 | ("Naming Conflicts", test_no_name_conflicts),
195 | ]
196 |
197 | results = {}
198 | all_passed = True
199 |
200 | for test_name, test_func in tests:
201 | try:
202 | result = test_func()
203 | results[test_name] = result
204 | if not result:
205 | all_passed = False
206 | except Exception as e:
207 | print(f"❌ {test_name} test crashed: {e}")
208 | results[test_name] = False
209 | all_passed = False
210 |
211 | # Summary
212 | print("\n🏁 INTEGRATION TEST RESULTS")
213 | print("="*30)
214 |
215 | for test_name, result in results.items():
216 | status = "✅ PASSED" if result else "❌ FAILED"
217 | print(f"{test_name}: {status}")
218 |
219 | print(f"\n📊 Overall Result: {'✅ ALL TESTS PASSED' if all_passed else '❌ SOME TESTS FAILED'}")
220 |
221 | if all_passed:
222 | print("\n🎉 Integration successful!")
223 | print(" • Existing Meta Ads tools: Working")
224 | print(" • New OpenAI MCP tools: Working")
225 | print(" • No conflicts detected")
226 | print(" • Ready for OpenAI ChatGPT Deep Research")
227 | print("\n📋 Next steps:")
228 | print(" 1. Start server inside uv virtual env:")
229 | print(" # Basic HTTP server (default: localhost:8080)")
230 | print(" python -m meta_ads_mcp --transport streamable-http")
231 | print(" ")
232 | print(" # Custom host and port")
233 | print(" python -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port 9000")
234 | print(" 2. Run OpenAI tests: python tests/test_openai_mcp_deep_research.py")
235 | else:
236 | print("\n⚠️ Integration issues detected")
237 | print(" Please fix failed tests before proceeding")
238 |
239 | return 0 if all_passed else 1
240 |
241 | if __name__ == "__main__":
242 | sys.exit(main())
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/callback_server.py:
--------------------------------------------------------------------------------
```python
1 | """Callback server for Meta Ads API authentication."""
2 |
3 | import threading
4 | import socket
5 | import asyncio
6 | import json
7 | import logging
8 | import webbrowser
9 | import os
10 | from http.server import HTTPServer, BaseHTTPRequestHandler
11 | from urllib.parse import urlparse, parse_qs, quote
12 | from typing import Dict, Any, Optional
13 |
14 | from .utils import logger
15 |
16 | # Global token container for communication between threads
17 | token_container = {"token": None, "expires_in": None, "user_id": None}
18 |
19 | # Global variables for server thread and state
20 | callback_server_thread = None
21 | callback_server_lock = threading.Lock()
22 | callback_server_running = False
23 | callback_server_port = None
24 | callback_server_instance = None
25 | server_shutdown_timer = None
26 |
27 | # Timeout in seconds before shutting down the callback server
28 | CALLBACK_SERVER_TIMEOUT = 180 # 3 minutes timeout
29 |
30 |
31 | class CallbackHandler(BaseHTTPRequestHandler):
32 | def do_GET(self):
33 | try:
34 | # Print path for debugging
35 | print(f"Callback server received request: {self.path}")
36 |
37 | if self.path.startswith("/callback"):
38 | self._handle_oauth_callback()
39 | elif self.path.startswith("/token"):
40 | self._handle_token()
41 | else:
42 | # If no matching path, return a 404 error
43 | self.send_response(404)
44 | self.end_headers()
45 | except Exception as e:
46 | print(f"Error processing request: {e}")
47 | self.send_response(500)
48 | self.end_headers()
49 |
50 | def _handle_oauth_callback(self):
51 | """Handle OAuth callback after user authorization"""
52 | # Check if we're being redirected from Facebook with an authorization code
53 | parsed_url = urlparse(self.path)
54 | params = parse_qs(parsed_url.query)
55 |
56 | # Check for code parameter
57 | code = params.get('code', [None])[0]
58 | state = params.get('state', [None])[0]
59 | error = params.get('error', [None])[0]
60 |
61 | # Send 200 OK response with a simple HTML page
62 | self.send_response(200)
63 | self.send_header("Content-type", "text/html")
64 | self.end_headers()
65 |
66 | if error:
67 | # User denied access or other error occurred
68 | html = f"""
69 | <html>
70 | <head><title>Authorization Failed</title></head>
71 | <body>
72 | <h1>Authorization Failed</h1>
73 | <p>Error: {error}</p>
74 | <p>The authorization was cancelled or failed. You can close this window.</p>
75 | </body>
76 | </html>
77 | """
78 | logger.error(f"OAuth authorization failed: {error}")
79 | elif code:
80 | # Success case - we have the authorization code
81 | logger.info(f"Received authorization code: {code[:10]}...")
82 |
83 | # Store the authorization code temporarily
84 | # The auth module will exchange this for an access token
85 | token_container.update({
86 | "auth_code": code,
87 | "state": state,
88 | "timestamp": asyncio.get_event_loop().time()
89 | })
90 |
91 | html = """
92 | <html>
93 | <head><title>Authorization Successful</title></head>
94 | <body>
95 | <h1>✅ Authorization Successful!</h1>
96 | <p>You have successfully authorized the Meta Ads MCP application.</p>
97 | <p>You can now close this window and return to your application.</p>
98 | <script>
99 | // Try to close the window automatically after 2 seconds
100 | setTimeout(function() {
101 | window.close();
102 | }, 2000);
103 | </script>
104 | </body>
105 | </html>
106 | """
107 | logger.info("OAuth authorization successful")
108 | else:
109 | # No code or error - something unexpected happened
110 | html = """
111 | <html>
112 | <head><title>Unexpected Response</title></head>
113 | <body>
114 | <h1>Unexpected Response</h1>
115 | <p>No authorization code or error received. Please try again.</p>
116 | </body>
117 | </html>
118 | """
119 | logger.warning("OAuth callback received without code or error")
120 |
121 | self.wfile.write(html.encode())
122 |
123 | def _handle_token(self):
124 | """Handle token endpoint for retrieving stored token data"""
125 | # This endpoint allows other parts of the application to retrieve
126 | # token information from the callback server
127 |
128 | self.send_response(200)
129 | self.send_header("Content-type", "application/json")
130 | self.end_headers()
131 |
132 | # Return current token container contents
133 | response_data = {
134 | "status": "success",
135 | "data": token_container
136 | }
137 |
138 | self.wfile.write(json.dumps(response_data).encode())
139 |
140 | # The actual token processing is now handled by the auth module
141 | # that imports this module and accesses token_container
142 |
143 | # Silence server logs
144 | def log_message(self, format, *args):
145 | return
146 |
147 |
148 | def shutdown_callback_server():
149 | """
150 | Shutdown the callback server if it's running
151 | """
152 | global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
153 |
154 | with callback_server_lock:
155 | if not callback_server_running:
156 | print("Callback server is not running")
157 | return
158 |
159 | if server_shutdown_timer is not None:
160 | server_shutdown_timer.cancel()
161 | server_shutdown_timer = None
162 |
163 | try:
164 | if callback_server_instance:
165 | print("Shutting down callback server...")
166 | callback_server_instance.shutdown()
167 | callback_server_instance.server_close()
168 | print("Callback server shut down successfully")
169 |
170 | if callback_server_thread and callback_server_thread.is_alive():
171 | callback_server_thread.join(timeout=5)
172 | if callback_server_thread.is_alive():
173 | print("Warning: Callback server thread did not shut down cleanly")
174 | except Exception as e:
175 | print(f"Error during callback server shutdown: {e}")
176 | finally:
177 | callback_server_running = False
178 | callback_server_thread = None
179 | callback_server_port = None
180 | callback_server_instance = None
181 |
182 |
183 | def start_callback_server() -> int:
184 | """
185 | Start the callback server and return the port number it's running on.
186 |
187 | Returns:
188 | int: Port number the server is listening on
189 |
190 | Raises:
191 | Exception: If the server fails to start
192 | """
193 | global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
194 |
195 | # Check if callback server is disabled
196 | if os.environ.get("META_ADS_DISABLE_CALLBACK_SERVER"):
197 | raise Exception("Callback server is disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable")
198 |
199 | with callback_server_lock:
200 | if callback_server_running:
201 | print(f"Callback server already running on port {callback_server_port}")
202 | return callback_server_port
203 |
204 | # Find an available port
205 | port = 8080
206 | max_attempts = 10
207 | for attempt in range(max_attempts):
208 | try:
209 | # Test if port is available
210 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
211 | s.bind(('localhost', port))
212 | break
213 | except OSError:
214 | port += 1
215 | else:
216 | raise Exception(f"Could not find an available port after {max_attempts} attempts")
217 |
218 | callback_server_port = port
219 |
220 | # Start the server in a separate thread
221 | callback_server_thread = threading.Thread(target=server_thread, daemon=True)
222 | callback_server_thread.start()
223 |
224 | # Wait a moment for the server to start
225 | import time
226 | time.sleep(0.5)
227 |
228 | if not callback_server_running:
229 | raise Exception("Failed to start callback server")
230 |
231 | # Set up automatic shutdown timer
232 | def auto_shutdown():
233 | print(f"Callback server auto-shutdown after {CALLBACK_SERVER_TIMEOUT} seconds")
234 | shutdown_callback_server()
235 |
236 | server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, auto_shutdown)
237 | server_shutdown_timer.start()
238 |
239 | print(f"Callback server started on http://localhost:{port}")
240 | return port
241 |
242 |
243 | def server_thread():
244 | """Thread function to run the callback server"""
245 | global callback_server_running, callback_server_instance
246 |
247 | try:
248 | callback_server_instance = HTTPServer(('localhost', callback_server_port), CallbackHandler)
249 | callback_server_running = True
250 | print(f"Callback server thread started on port {callback_server_port}")
251 | callback_server_instance.serve_forever()
252 | except Exception as e:
253 | print(f"Callback server error: {e}")
254 | callback_server_running = False
255 | finally:
256 | print("Callback server thread finished")
257 | callback_server_running = False
```
--------------------------------------------------------------------------------
/meta_ads_mcp/core/utils.py:
--------------------------------------------------------------------------------
```python
1 | """Utility functions for Meta Ads API."""
2 |
3 | from typing import Optional, Dict, Any, List
4 | import httpx
5 | import io
6 | from PIL import Image as PILImage
7 | import base64
8 | import time
9 | import asyncio
10 | import os
11 | import json
12 | import logging
13 | import pathlib
14 | import platform
15 |
16 | # Check for Meta app credentials in environment
17 | META_APP_ID = os.environ.get("META_APP_ID", "")
18 | META_APP_SECRET = os.environ.get("META_APP_SECRET", "")
19 |
20 | # Only show warnings about Meta credentials if we're not using Pipeboard
21 | # Check for Pipeboard token in environment
22 | using_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
23 |
24 | # Print warning if Meta app credentials are not configured and not using Pipeboard
25 | if not using_pipeboard:
26 | if not META_APP_ID:
27 | print("WARNING: META_APP_ID environment variable is not set.")
28 | print("RECOMMENDED: Use Pipeboard authentication by setting PIPEBOARD_API_TOKEN instead.")
29 | print("ALTERNATIVE: For direct Meta authentication, set META_APP_ID to your Meta App ID.")
30 | if not META_APP_SECRET:
31 | print("WARNING: META_APP_SECRET environment variable is not set.")
32 | print("NOTE: This is only needed for direct Meta authentication. Pipeboard authentication doesn't require this.")
33 | print("RECOMMENDED: Use Pipeboard authentication by setting PIPEBOARD_API_TOKEN instead.")
34 |
35 | # Configure logging to file
36 | def setup_logging():
37 | """Set up logging to file for troubleshooting."""
38 | # Get platform-specific path for logs
39 | if platform.system() == "Windows":
40 | base_path = pathlib.Path(os.environ.get("APPDATA", ""))
41 | elif platform.system() == "Darwin": # macOS
42 | base_path = pathlib.Path.home() / "Library" / "Application Support"
43 | else: # Assume Linux/Unix
44 | base_path = pathlib.Path.home() / ".config"
45 |
46 | # Create directory if it doesn't exist
47 | log_dir = base_path / "meta-ads-mcp"
48 | log_dir.mkdir(parents=True, exist_ok=True)
49 |
50 | log_file = log_dir / "meta_ads_debug.log"
51 |
52 | # Configure file logger
53 | logging.basicConfig(
54 | level=logging.DEBUG,
55 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
56 | filename=str(log_file),
57 | filemode='a' # Append mode
58 | )
59 |
60 | # Create a logger
61 | logger = logging.getLogger("meta-ads-mcp")
62 | logger.setLevel(logging.DEBUG)
63 |
64 | # Log startup information
65 | logger.info(f"Logging initialized. Log file: {log_file}")
66 | logger.info(f"Platform: {platform.system()} {platform.release()}")
67 | logger.info(f"Using Pipeboard authentication: {using_pipeboard}")
68 |
69 | return logger
70 |
71 | # Create the logger instance to be imported by other modules
72 | logger = setup_logging()
73 |
74 | # Global store for ad creative images
75 | ad_creative_images = {}
76 |
77 |
78 | def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
79 | """
80 | Extract image URLs from a creative object for direct viewing.
81 | Prioritizes higher quality images over thumbnails.
82 |
83 | Args:
84 | creative: Meta Ads creative object
85 |
86 | Returns:
87 | List of image URLs found in the creative, prioritized by quality
88 | """
89 | image_urls = []
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 |
102 | # Check for direct image_url field
103 | if "image_url" in creative and creative["image_url"]:
104 | image_urls.append(creative["image_url"])
105 |
106 | # Check object_story_spec for image URLs
107 | if "object_story_spec" in creative:
108 | story_spec = creative["object_story_spec"]
109 |
110 | # Check link_data for image fields
111 | if "link_data" in story_spec:
112 | link_data = story_spec["link_data"]
113 |
114 | # Check for picture field (usually full size)
115 | if "picture" in link_data and link_data["picture"]:
116 | image_urls.append(link_data["picture"])
117 |
118 | # Check for image_url field in link_data
119 | if "image_url" in link_data and link_data["image_url"]:
120 | image_urls.append(link_data["image_url"])
121 |
122 | # Check video_data for thumbnail (if present)
123 | if "video_data" in story_spec and "image_url" in story_spec["video_data"]:
124 | image_urls.append(story_spec["video_data"]["image_url"])
125 |
126 | # Check asset_feed_spec for multiple images
127 | if "asset_feed_spec" in creative and "images" in creative["asset_feed_spec"]:
128 | for image in creative["asset_feed_spec"]["images"]:
129 | if "url" in image and image["url"]:
130 | image_urls.append(image["url"])
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 |
136 | # Remove duplicates while preserving order
137 | seen = set()
138 | unique_urls = []
139 | for url in image_urls:
140 | if url not in seen:
141 | seen.add(url)
142 | unique_urls.append(url)
143 |
144 | return unique_urls
145 |
146 |
147 | async def download_image(url: str) -> Optional[bytes]:
148 | """
149 | Download an image from a URL.
150 |
151 | Args:
152 | url: Image URL
153 |
154 | Returns:
155 | Image data as bytes if successful, None otherwise
156 | """
157 | try:
158 | print(f"Attempting to download image from URL: {url}")
159 |
160 | # Use minimal headers like curl does
161 | headers = {
162 | "User-Agent": "curl/8.4.0",
163 | "Accept": "*/*"
164 | }
165 |
166 | async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
167 | # Simple GET request just like curl
168 | response = await client.get(url, headers=headers)
169 |
170 | # Check response
171 | if response.status_code == 200:
172 | print(f"Successfully downloaded image: {len(response.content)} bytes")
173 | return response.content
174 | else:
175 | print(f"Failed to download image: HTTP {response.status_code}")
176 | return None
177 |
178 | except httpx.HTTPStatusError as e:
179 | print(f"HTTP Error when downloading image: {e}")
180 | return None
181 | except httpx.RequestError as e:
182 | print(f"Request Error when downloading image: {e}")
183 | return None
184 | except Exception as e:
185 | print(f"Unexpected error downloading image: {e}")
186 | return None
187 |
188 |
189 | async def try_multiple_download_methods(url: str) -> Optional[bytes]:
190 | """
191 | Try multiple methods to download an image, with different approaches for Meta CDN.
192 |
193 | Args:
194 | url: Image URL
195 |
196 | Returns:
197 | Image data as bytes if successful, None otherwise
198 | """
199 | # Method 1: Direct download with custom headers
200 | image_data = await download_image(url)
201 | if image_data:
202 | return image_data
203 |
204 | print("Direct download failed, trying alternative methods...")
205 |
206 | # Method 2: Try adding Facebook cookie simulation
207 | try:
208 | headers = {
209 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
210 | "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
211 | "Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC" # Fake cookie
212 | }
213 |
214 | async with httpx.AsyncClient(follow_redirects=True) as client:
215 | response = await client.get(url, headers=headers, timeout=30.0)
216 | response.raise_for_status()
217 | print(f"Method 2 succeeded with cookie simulation: {len(response.content)} bytes")
218 | return response.content
219 | except Exception as e:
220 | print(f"Method 2 failed: {str(e)}")
221 |
222 | # Method 3: Try with session that keeps redirects and cookies
223 | try:
224 | async with httpx.AsyncClient(follow_redirects=True) as client:
225 | # First visit Facebook to get cookies
226 | await client.get("https://www.facebook.com/", timeout=30.0)
227 | # Then try the image URL
228 | response = await client.get(url, timeout=30.0)
229 | response.raise_for_status()
230 | print(f"Method 3 succeeded with Facebook session: {len(response.content)} bytes")
231 | return response.content
232 | except Exception as e:
233 | print(f"Method 3 failed: {str(e)}")
234 |
235 | return None
236 |
237 |
238 | def create_resource_from_image(image_bytes: bytes, resource_id: str, name: str) -> Dict[str, Any]:
239 | """
240 | Create a resource entry from image bytes.
241 |
242 | Args:
243 | image_bytes: Raw image data
244 | resource_id: Unique identifier for the resource
245 | name: Human-readable name for the resource
246 |
247 | Returns:
248 | Dictionary with resource information
249 | """
250 | ad_creative_images[resource_id] = {
251 | "data": image_bytes,
252 | "mime_type": "image/jpeg",
253 | "name": name
254 | }
255 |
256 | return {
257 | "resource_id": resource_id,
258 | "resource_uri": f"meta-ads://images/{resource_id}",
259 | "name": name,
260 | "size": len(image_bytes)
261 | }
```
--------------------------------------------------------------------------------
/STREAMABLE_HTTP_SETUP.md:
--------------------------------------------------------------------------------
```markdown
1 | # Streamable HTTP Transport Setup
2 |
3 | ## Overview
4 |
5 | Meta Ads MCP supports **Streamable HTTP Transport**, which allows you to run the server as a standalone HTTP API. This enables direct integration with web applications, custom dashboards, and any system that can make HTTP requests.
6 |
7 | ## Quick Start
8 |
9 | ### 1. Start the HTTP Server
10 |
11 | ```bash
12 | # Basic HTTP server (default: localhost:8080)
13 | python -m meta_ads_mcp --transport streamable-http
14 |
15 | # Custom host and port
16 | python -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port 9000
17 | ```
18 |
19 | ### 2. Set Authentication
20 |
21 | Set your Pipeboard token as an environment variable. This is optional for HTTP transport if you provide the token in the header, but it can be useful for command-line use.
22 |
23 | ```bash
24 | export PIPEBOARD_API_TOKEN=your_pipeboard_token
25 | ```
26 |
27 | ### 3. Make HTTP Requests
28 |
29 | The server accepts JSON-RPC 2.0 requests at the `/mcp` endpoint. Use the `Authorization` header to provide your token.
30 |
31 | ```bash
32 | curl -X POST http://localhost:8080/mcp \
33 | -H "Content-Type: application/json" \
34 | -H "Accept: application/json, text/event-stream" \
35 | -H "Authorization: Bearer your_pipeboard_token" \
36 | -d '{
37 | "jsonrpc": "2.0",
38 | "method": "tools/call",
39 | "id": 1,
40 | "params": {
41 | "name": "get_ad_accounts",
42 | "arguments": {"limit": 5}
43 | }
44 | }'
45 | ```
46 |
47 | ## Configuration Options
48 |
49 | ### Command Line Arguments
50 |
51 | | Argument | Description | Default |
52 | |----------|-------------|---------|
53 | | `--transport` | Transport mode | `stdio` |
54 | | `--host` | Server host address | `localhost` |
55 | | `--port` | Server port | `8080` |
56 |
57 | ### Examples
58 |
59 | ```bash
60 | # Local development server
61 | python -m meta_ads_mcp --transport streamable-http --host localhost --port 8080
62 |
63 | # Production server (accessible externally)
64 | python -m meta_ads_mcp --transport streamable-http --host 0.0.0.0 --port 8080
65 |
66 | # Custom port
67 | python -m meta_ads_mcp --transport streamable-http --port 9000
68 | ```
69 |
70 | ## Authentication
71 |
72 | ### Primary Method: Bearer Token (Recommended)
73 |
74 | 1. Sign up at [Pipeboard.co](https://pipeboard.co)
75 | 2. Generate an API token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens)
76 | 3. Include the token in the `Authorization` HTTP header:
77 |
78 | ```bash
79 | curl -H "Authorization: Bearer your_pipeboard_token" \
80 | -X POST http://localhost:8080/mcp \
81 | -H "Content-Type: application/json" \
82 | -H "Accept: application/json, text/event-stream" \
83 | -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
84 | ```
85 |
86 | ### Alternative Method: Direct Meta Token
87 |
88 | If you have a Meta Developer App, you can use a direct access token via the `X-META-ACCESS-TOKEN` header. This is less common.
89 |
90 | ```bash
91 | curl -H "X-META-ACCESS-TOKEN: your_meta_access_token" \
92 | -X POST http://localhost:8080/mcp \
93 | -H "Content-Type: application/json" \
94 | -H "Accept: application/json, text/event-stream" \
95 | -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
96 | ```
97 |
98 | ## Available Endpoints
99 |
100 | ### Server URL Structure
101 |
102 | **Base URL**: `http://localhost:8080`
103 | **MCP Endpoint**: `/mcp`
104 |
105 | ### MCP Protocol Methods
106 |
107 | | Method | Description |
108 | |--------|-------------|
109 | | `initialize` | Initialize MCP session and exchange capabilities |
110 | | `tools/list` | Get list of all available Meta Ads tools |
111 | | `tools/call` | Execute a specific tool with parameters |
112 |
113 | ### Response Format
114 |
115 | All responses follow JSON-RPC 2.0 format:
116 |
117 | ```json
118 | {
119 | "jsonrpc": "2.0",
120 | "id": 1,
121 | "result": {
122 | // Tool response data
123 | }
124 | }
125 | ```
126 |
127 | ## Example Usage
128 |
129 | ### 1. Initialize Session
130 |
131 | ```bash
132 | curl -X POST http://localhost:8080/mcp \
133 | -H "Content-Type: application/json" \
134 | -H "Accept: application/json, text/event-stream" \
135 | -H "Authorization: Bearer your_token" \
136 | -d '{
137 | "jsonrpc": "2.0",
138 | "method": "initialize",
139 | "id": 1,
140 | "params": {
141 | "protocolVersion": "2024-11-05",
142 | "capabilities": {"roots": {"listChanged": true}},
143 | "clientInfo": {"name": "my-app", "version": "1.0.0"}
144 | }
145 | }'
146 | ```
147 |
148 | ### 2. List Available Tools
149 |
150 | ```bash
151 | curl -X POST http://localhost:8080/mcp \
152 | -H "Content-Type: application/json" \
153 | -H "Accept: application/json, text/event-stream" \
154 | -H "Authorization: Bearer your_token" \
155 | -d '{
156 | "jsonrpc": "2.0",
157 | "method": "tools/list",
158 | "id": 2
159 | }'
160 | ```
161 |
162 | ### 3. Get Ad Accounts
163 |
164 | ```bash
165 | curl -X POST http://localhost:8080/mcp \
166 | -H "Content-Type: application/json" \
167 | -H "Accept: application/json, text/event-stream" \
168 | -H "Authorization: Bearer your_token" \
169 | -d '{
170 | "jsonrpc": "2.0",
171 | "method": "tools/call",
172 | "id": 3,
173 | "params": {
174 | "name": "get_ad_accounts",
175 | "arguments": {"limit": 10}
176 | }
177 | }'
178 | ```
179 |
180 | ### 4. Get Campaign Performance
181 |
182 | ```bash
183 | curl -X POST http://localhost:8080/mcp \
184 | -H "Content-Type: application/json" \
185 | -H "Accept: application/json, text/event-stream" \
186 | -H "Authorization: Bearer your_token" \
187 | -d '{
188 | "jsonrpc": "2.0",
189 | "method": "tools/call",
190 | "id": 4,
191 | "params": {
192 | "name": "get_insights",
193 | "arguments": {
194 | "object_id": "act_701351919139047",
195 | "time_range": "last_30d",
196 | "level": "campaign"
197 | }
198 | }
199 | }'
200 | ```
201 |
202 | ## Client Examples
203 |
204 | ### Python Client
205 |
206 | ```python
207 | import requests
208 | import json
209 |
210 | class MetaAdsMCPClient:
211 | def __init__(self, base_url="http://localhost:8080", token=None):
212 | self.base_url = base_url
213 | self.endpoint = f"{base_url}/mcp"
214 | self.headers = {
215 | "Content-Type": "application/json",
216 | "Accept": "application/json, text/event-stream"
217 | }
218 | if token:
219 | self.headers["Authorization"] = f"Bearer {token}"
220 |
221 | def call_tool(self, tool_name, arguments=None):
222 | payload = {
223 | "jsonrpc": "2.0",
224 | "method": "tools/call",
225 | "id": 1,
226 | "params": {"name": tool_name}
227 | }
228 | if arguments:
229 | payload["params"]["arguments"] = arguments
230 |
231 | response = requests.post(self.endpoint, headers=self.headers, json=payload)
232 | return response.json()
233 |
234 | # Usage
235 | client = MetaAdsMCPClient(token="your_pipeboard_token")
236 | result = client.call_tool("get_ad_accounts", {"limit": 5})
237 | print(json.dumps(result, indent=2))
238 | ```
239 |
240 | ### JavaScript/Node.js Client
241 |
242 | ```javascript
243 | const axios = require('axios');
244 |
245 | class MetaAdsMCPClient {
246 | constructor(baseUrl = 'http://localhost:8080', token = null) {
247 | this.baseUrl = baseUrl;
248 | this.endpoint = `${baseUrl}/mcp`;
249 | this.headers = {
250 | 'Content-Type': 'application/json',
251 | 'Accept': 'application/json, text/event-stream'
252 | };
253 | if (token) {
254 | this.headers['Authorization'] = `Bearer ${token}`;
255 | }
256 | }
257 |
258 | async callTool(toolName, arguments = null) {
259 | const payload = {
260 | jsonrpc: '2.0',
261 | method: 'tools/call',
262 | id: 1,
263 | params: { name: toolName }
264 | };
265 | if (arguments) {
266 | payload.params.arguments = arguments;
267 | }
268 |
269 | try {
270 | const response = await axios.post(this.endpoint, payload, { headers: this.headers });
271 | return response.data;
272 | } catch (error) {
273 | return { error: error.message };
274 | }
275 | }
276 | }
277 |
278 | // Usage
279 | const client = new MetaAdsMCPClient('http://localhost:8080', 'your_pipeboard_token');
280 | client.callTool('get_ad_accounts', { limit: 5 })
281 | .then(result => console.log(JSON.stringify(result, null, 2)));
282 | ```
283 |
284 | ## Production Deployment
285 |
286 | ### Security Considerations
287 |
288 | 1. **Use HTTPS**: In production, run behind a reverse proxy with SSL/TLS
289 | 2. **Authentication**: Always use valid Bearer tokens.
290 | 3. **Network Security**: Configure firewalls and access controls appropriately
291 | 4. **Rate Limiting**: Consider implementing rate limiting for public APIs
292 |
293 | ### Docker Deployment
294 |
295 | ```dockerfile
296 | FROM python:3.10-slim
297 |
298 | WORKDIR /app
299 | COPY . .
300 | RUN pip install -e .
301 |
302 | EXPOSE 8080
303 |
304 | CMD ["python", "-m", "meta_ads_mcp", "--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8080"]
305 | ```
306 |
307 | ### Environment Variables
308 |
309 | ```bash
310 | # For Pipeboard-based authentication. The token will be used for stdio,
311 | # but for HTTP it should be passed in the Authorization header.
312 | export PIPEBOARD_API_TOKEN=your_pipeboard_token
313 |
314 | # Optional (for custom Meta apps)
315 | export META_APP_ID=your_app_id
316 | export META_APP_SECRET=your_app_secret
317 |
318 | # Optional (for direct Meta token)
319 | export META_ACCESS_TOKEN=your_access_token
320 | ```
321 |
322 | ## Troubleshooting
323 |
324 | ### Common Issues
325 |
326 | 1. **Connection Refused**: Ensure the server is running and accessible on the specified port.
327 | 2. **Authentication Failed**: Verify your Bearer token is valid and included in the `Authorization` header.
328 | 3. **404 Not Found**: Make sure you're using the correct endpoint (`/mcp`).
329 | 4. **JSON-RPC Errors**: Check that your request follows the JSON-RPC 2.0 format.
330 |
331 | ### Debug Mode
332 |
333 | Enable verbose logging by setting the log level in your environment if the application supports it, or check the application's logging configuration. The current implementation logs to a file.
334 |
335 | ### Health Check
336 |
337 | Test if the server is running by sending a `tools/list` request:
338 |
339 | ```bash
340 | curl -X POST http://localhost:8080/mcp \
341 | -H "Content-Type: application/json" \
342 | -H "Accept: application/json, text/event-stream" \
343 | -H "Authorization: Bearer your_token" \
344 | -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
345 | ```
346 |
347 | ## Migration from stdio
348 |
349 | If you're currently using stdio transport with MCP clients, you can support both stdio for local clients and HTTP for web applications. The application can only run in one mode at a time, so you may need to run two separate instances if you need both simultaneously.
350 |
351 | 1. **Keep existing MCP client setup** (Claude Desktop, Cursor, etc.) using stdio.
352 | 2. **Add HTTP transport** for web applications and custom integrations by running a separate server instance with the `--transport streamable-http` flag.
353 | 3. **Use the same authentication method**:
354 | - For stdio, the `PIPEBOARD_API_TOKEN` environment variable is used.
355 | - For HTTP, pass the token in the `Authorization: Bearer <token>` header.
356 |
357 | Both transports access the same Meta Ads functionality and use the same underlying authentication system.
```