Skip to content

Commit 088a80e

Browse files
committed
chore: merge upstream/main (PM-Labs changes preserved)
2 parents 4aa5fdc + 4e339ad commit 088a80e

11 files changed

Lines changed: 443 additions & 75 deletions

‎README.md‎

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Meta Ads MCP
22

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.
3+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that lets AI assistants run your Meta Ads end to end — launch campaigns, upload creatives, update budgets, and dig into performance through natural conversation. Works across Facebook, Instagram, and other Meta surfaces.
4+
5+
Part of the [Pipeboard](https://pipeboard.co) family of ad connectors — sibling remote MCP servers cover **Google Ads**, **TikTok Ads**, **Snap Ads**, and **Reddit Ads** with the same setup (see [Also Available](#also-available-google-ads-tiktok-ads-snap-ads-and-reddit-ads) below).
46

57
> **Note:** This is an independent open-source project that uses Meta's public APIs. If you're looking for an officially approved Meta app, check out [Pipeboard](https://pipeboard.co). Meta, Facebook, Instagram, and other Meta brand names are trademarks of their respective owners.
68
@@ -30,14 +32,14 @@ mcp-name: co.pipeboard/meta-ads-mcp
3032

3133
## Getting started with Remote MCP (Recommended)
3234

33-
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!
35+
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 your Meta Ads account. No technical setup required — just connect and start launching, updating, and analyzing campaigns with AI!
3436

3537
### For Claude Pro/Max Users
3638

3739
1. Go to [claude.ai/settings/integrations](https://claude.ai/settings/integrations) (requires Claude Pro or Max)
3840
2. Click "Add Integration" and enter:
3941
- **Name**: "Pipeboard Meta Ads" (or any name you prefer)
40-
- **Integration URL**: `https://mcp.pipeboard.co/meta-ads-mcp`
42+
- **Integration URL**: `https://meta-ads.mcp.pipeboard.co/`
4143
3. Click "Connect" next to the integration and follow the prompts to:
4244
- Login to Pipeboard
4345
- Connect your Facebook Ads account
@@ -49,7 +51,7 @@ That's it! You can now ask Claude to analyze your Meta ad campaigns, get perform
4951
For direct token-based authentication without the interactive flow, use this URL format when adding the integration:
5052

5153
```
52-
https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN
54+
https://meta-ads.mcp.pipeboard.co/?token=YOUR_PIPEBOARD_TOKEN
5355
```
5456

5557
Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
@@ -63,7 +65,7 @@ Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP,
6365
{
6466
"mcpServers": {
6567
"meta-ads-remote": {
66-
"url": "https://mcp.pipeboard.co/meta-ads-mcp"
68+
"url": "https://meta-ads.mcp.pipeboard.co/"
6769
}
6870
}
6971
}
@@ -77,7 +79,7 @@ If you prefer to authenticate without the interactive login flow, you can includ
7779
{
7880
"mcpServers": {
7981
"meta-ads-remote": {
80-
"url": "https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN"
82+
"url": "https://meta-ads.mcp.pipeboard.co/?token=YOUR_PIPEBOARD_TOKEN"
8183
}
8284
}
8385
}
@@ -87,7 +89,7 @@ Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
8789

8890
### For Other MCP Clients
8991

90-
Use the Remote MCP URL: `https://mcp.pipeboard.co/meta-ads-mcp`
92+
Use the Remote MCP URL: `https://meta-ads.mcp.pipeboard.co/`
9193

9294
**[📖 Get detailed setup instructions for your AI client here](https://pipeboard.co)**
9395

@@ -96,22 +98,24 @@ Use the Remote MCP URL: `https://mcp.pipeboard.co/meta-ads-mcp`
9698
For MCP clients that support token-based authentication, you can append your Pipeboard API token to the URL:
9799

98100
```
99-
https://mcp.pipeboard.co/meta-ads-mcp?token=YOUR_PIPEBOARD_TOKEN
101+
https://meta-ads.mcp.pipeboard.co/?token=YOUR_PIPEBOARD_TOKEN
100102
```
101103

102104
This bypasses the interactive login flow and authenticates immediately. Get your token at [pipeboard.co/api-tokens](https://pipeboard.co/api-tokens).
103105

104-
### Also Available: Google Ads and TikTok Ads
106+
### Also Available: Google Ads, TikTok Ads, Snap Ads, and Reddit Ads
105107

106-
Pipeboard also offers remote MCP servers for **Google Ads** and **TikTok Ads**, set up the same way:
108+
Pipeboard offers remote MCP servers for every major ad platform — all set up the same way and all built for the full launch/manage/analyze loop:
107109

108110
| Platform | Remote MCP URL |
109111
|---|---|
110-
| Meta Ads | `https://mcp.pipeboard.co/meta-ads-mcp` |
111-
| Google Ads | `https://mcp.pipeboard.co/google-ads-mcp` |
112-
| TikTok Ads | `https://mcp.pipeboard.co/tiktok-ads-mcp` |
112+
| Meta Ads | `https://meta-ads.mcp.pipeboard.co/` |
113+
| Google Ads | `https://google-ads.mcp.pipeboard.co/` |
114+
| TikTok Ads | `https://tiktok-ads.mcp.pipeboard.co/` |
115+
| Snap Ads | `https://snap-ads.mcp.pipeboard.co/` |
116+
| Reddit Ads | `https://reddit-ads.mcp.pipeboard.co/` |
113117

114-
Connect your ad accounts at [pipeboard.co](https://pipeboard.co) and use any of them with Claude, Cursor, or any MCP client.
118+
Connect your ad accounts at [pipeboard.co](https://pipeboard.co) and use any of them with Claude, Cursor, or any MCP-compatible assistant.
115119

116120
## Pipeboard CLI (Alternative to MCP)
117121

@@ -147,14 +151,15 @@ Meta Ads MCP also supports a local streamable HTTP transport, allowing you to ru
147151

148152
## Features
149153

150-
- **AI-Powered Campaign Analysis**: Let your favorite LLM analyze your campaigns and provide actionable insights on performance
154+
- **Campaign Management**: Launch campaigns, ad sets, and ads, update budgets, pause and resume, and apply targeting changes — all from a conversation, with explicit confirmation on every write
155+
- **Creative Operations**: Upload images, build creatives, and update copy, headlines, descriptions, and CTAs without leaving your AI client
156+
- **Dynamic Creative Testing**: One API for both simple ads (single headline/description) and full A/B testing (multiple headlines/descriptions)
157+
- **AI-Powered Campaign Analysis**: Let your favorite LLM analyze performance and surface actionable insights
151158
- **Strategic Recommendations**: Receive data-backed suggestions for optimizing ad spend, targeting, and creative content
152-
- **Automated Monitoring**: Ask any MCP-compatible LLM to track performance metrics and alert you about significant changes
153159
- **Budget Optimization**: Get recommendations for reallocating budget to better-performing ad sets
154160
- **Creative Improvement**: Receive feedback on ad copy, imagery, and calls-to-action
155-
- **Dynamic Creative Testing**: Easy API for both simple ads (single headline/description) and advanced A/B testing (multiple headlines/descriptions)
156-
- **Campaign Management**: Request changes to campaigns, ad sets, and ads (all changes require explicit confirmation)
157-
- **Cross-Platform Integration**: Works with Facebook, Instagram, and all Meta ad platforms
161+
- **Automated Monitoring**: Ask any MCP-compatible LLM to track performance metrics and alert you about significant changes
162+
- **Cross-Platform Integration**: Works with Facebook, Instagram, and all Meta ad surfaces
158163
- **Universal LLM Support**: Compatible with any MCP client including Claude Desktop, Cursor, Cherry Studio, and more
159164
- **Enhanced Search**: Generic search function includes page searching when queries mention "page" or "pages"
160165
- **Simple Authentication**: Easy setup with secure OAuth authentication

‎SECURITY.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Security
2+
3+
## Reporting a vulnerability
4+
5+
Please report security issues privately via GitHub: open a draft advisory
6+
from the [Security tab](https://github.com/pipeboard-co/meta-ads-mcp/security/advisories/new)
7+
("Report a vulnerability"). Do not file public issues for unpatched
8+
vulnerabilities.
9+
10+
## Advisories
11+
12+
### GHSA-9gw6-46qc-99vr — Unauthenticated HTTP MCP tool execution leaks operator Meta access token
13+
14+
- **Severity:** Critical (CVSS 3.1 9.1 — `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`)
15+
- **Affected versions:** `<= 1.0.108` when run with `--transport streamable-http`
16+
and a `META_ACCESS_TOKEN` environment variable.
17+
- **Fixed in:** `1.0.109`
18+
- **Affected configurations:** Self-hosted deployments that expose the
19+
streamable-HTTP port on a reachable network interface. The hosted MCP at
20+
`*.mcp.pipeboard.co` was not affected — it sits behind an authenticating
21+
proxy and the Python process is bound to localhost.
22+
23+
**What went wrong.** `AuthInjectionMiddleware.dispatch()` logged a warning when
24+
a request arrived with no `Authorization: Bearer` / `X-PIPEBOARD-API-TOKEN`
25+
header and then forwarded the request to the tool handler anyway. Tool handlers
26+
fall back to `META_ACCESS_TOKEN` when no per-request token is set, so any
27+
network-reachable caller could invoke any MCP tool as the operator. When the
28+
downstream Graph API call returned a 4xx, `make_api_request()` serialized
29+
`e.request.url` — including `access_token` as a query parameter — verbatim into
30+
the JSON-RPC error payload, exposing the long-lived operator credential.
31+
32+
**Fix.**
33+
1. `AuthInjectionMiddleware` now returns `401 Unauthorized` with
34+
`WWW-Authenticate: Bearer` when neither token header is present.
35+
2. `make_api_request()` redacts `access_token` and `appsecret_proof` from any
36+
URLs returned in error payloads (`_redact_url` helper).
37+
38+
**Action for operators.**
39+
- Upgrade to `1.0.109` or later.
40+
- HTTP clients must send `Authorization: Bearer <meta-access-token>` (or the
41+
legacy `X-PIPEBOARD-API-TOKEN` header) on every request. The
42+
`META_ACCESS_TOKEN` env var is no longer used as an implicit fallback for
43+
HTTP transport.
44+
- If you exposed an earlier version to an untrusted network, rotate the Meta
45+
access token (`https://developers.facebook.com/tools/debug/accesstoken/`)
46+
and review Graph API access logs for unexpected calls.

‎meta_ads_mcp/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from meta_ads_mcp.core.server import main
88

9-
__version__ = "1.0.104"
9+
__version__ = "1.0.110"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/ads.py‎

Lines changed: 83 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -717,12 +717,15 @@ async def get_ad_creatives(ad_id: str, access_token: Optional[str] = None) -> st
717717
@meta_api_tool
718718
async def get_ad_image(ad_id: str, access_token: Optional[str] = None) -> Image:
719719
"""
720-
Get, download, and visualize a Meta ad image in one step. Useful to see the image in the LLM.
721-
720+
Get, download, and visualize the image attached to an existing Meta ad.
721+
722+
Takes a Meta ad ID and returns the image the ad is currently serving.
723+
If all you have is an image hash (no ad), use get_image_by_hash instead.
724+
722725
Args:
723726
ad_id: Meta Ads ad ID
724727
access_token: Meta API access token (optional - will use cached token if not provided)
725-
728+
726729
Returns:
727730
The ad image ready for direct visual analysis
728731
"""
@@ -859,58 +862,89 @@ async def get_ad_image(ad_id: str, access_token: Optional[str] = None) -> Image:
859862
return f"Error processing image from direct URL: {str(e)}"
860863

861864
print(f"Found image hashes: {image_hashes}")
862-
863-
# Now fetch image data using adimages endpoint with specific format
865+
866+
return await _fetch_image_by_hash(account_id, image_hashes[0], access_token)
867+
868+
869+
@mcp_server.tool()
870+
@meta_api_tool
871+
async def get_image_by_hash(
872+
account_id: str,
873+
image_hash: str,
874+
access_token: Optional[str] = None,
875+
) -> Image:
876+
"""
877+
Get, download, and visualize a Meta ad image by its hash.
878+
879+
Use this when you have an image_hash without an ad — e.g. the hash
880+
returned by upload_ad_image / bulk_upload_ad_images, or one referenced
881+
in a creative (object_story_spec.link_data.image_hash, asset_feed_spec
882+
images[].hash, etc.). To view the image of an existing ad, prefer
883+
get_ad_image(ad_id).
884+
885+
Args:
886+
account_id: Meta Ads account ID (act_XXXXXXXXX or bare numeric — both accepted)
887+
image_hash: Meta image hash
888+
access_token: Meta API access token (optional - will use cached token if not provided)
889+
890+
Returns:
891+
The image ready for direct visual analysis
892+
"""
893+
if not account_id:
894+
return "Error: No account ID provided"
895+
if not image_hash:
896+
return "Error: No image hash provided"
897+
normalized_account = ensure_act_prefix(account_id).removeprefix("act_")
898+
return await _fetch_image_by_hash(normalized_account, image_hash, access_token)
899+
900+
901+
async def _fetch_image_by_hash(
902+
account_id: str,
903+
image_hash: str,
904+
access_token: Optional[str],
905+
) -> Union[Image, str]:
906+
"""Fetch a single image from act_{account_id}/adimages by hash and return it as a PIL Image.
907+
908+
account_id is the bare numeric part (without the "act_" prefix). Returns
909+
an "Error: ..." string on failure so the @meta_api_tool wrapper can
910+
serialize it.
911+
"""
864912
image_endpoint = f"act_{account_id}/adimages"
865-
866-
# Format the hashes parameter exactly as in our successful curl test
867-
hashes_str = f'["{image_hashes[0]}"]' # Format first hash only, as JSON string array
868-
913+
hashes_str = f'["{image_hash}"]'
869914
image_params = {
870915
"fields": "hash,url,width,height,name,status",
871-
"hashes": hashes_str
916+
"hashes": hashes_str,
872917
}
873-
918+
874919
print(f"Requesting image data with params: {image_params}")
875920
image_data = await make_api_request(image_endpoint, access_token, image_params)
876-
921+
877922
if "error" in image_data:
878923
return f"Error: Failed to get image data - {json.dumps(image_data)}"
879-
924+
880925
if "data" not in image_data or not image_data["data"]:
881-
return "Error: No image data returned from API"
882-
883-
# Get the first image URL
884-
first_image = image_data["data"][0]
885-
image_url = first_image.get("url")
886-
926+
return (
927+
f"Error: No image found for hash {image_hash} in account "
928+
f"act_{account_id}. Confirm the hash and that the account matches "
929+
"the one used to upload (image hashes are scoped per ad account)."
930+
)
931+
932+
image_url = image_data["data"][0].get("url")
887933
if not image_url:
888934
return "Error: No valid image URL found"
889-
935+
890936
print(f"Downloading image from URL: {image_url}")
891-
892-
# Download the image
893937
image_bytes = await download_image(image_url)
894-
895938
if not image_bytes:
896939
return "Error: Failed to download image"
897-
940+
898941
try:
899-
# Convert bytes to PIL Image
900942
img = PILImage.open(io.BytesIO(image_bytes))
901-
902-
# Convert to RGB if needed
903943
if img.mode != "RGB":
904944
img = img.convert("RGB")
905-
906-
# Create a byte stream of the image data
907945
byte_arr = io.BytesIO()
908946
img.save(byte_arr, format="JPEG")
909-
img_bytes = byte_arr.getvalue()
910-
911-
# Return as an Image object that LLM can directly analyze
912-
return Image(data=img_bytes, format="jpeg")
913-
947+
return Image(data=byte_arr.getvalue(), format="jpeg")
914948
except Exception as e:
915949
return f"Error processing image: {str(e)}"
916950

@@ -1250,16 +1284,21 @@ async def upload_ad_image(
12501284
) -> str:
12511285
"""
12521286
Upload an image to use in Meta Ads creatives.
1253-
1287+
12541288
Args:
12551289
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
12561290
access_token: Meta API access token (optional - will use cached token if not provided)
12571291
file: Data URL or raw base64 string of the image (e.g., "data:image/png;base64,iVBORw0KG...")
12581292
image_url: Direct URL to an image to fetch and upload
12591293
name: Optional name for the image (default: filename)
1260-
1294+
12611295
Returns:
1262-
JSON response with image details including hash for creative creation
1296+
JSON object with:
1297+
- image_hash: Pass this to create_ad_creative when building the ad,
1298+
or to get_image_by_hash to view the image later.
1299+
- images: List of {hash, url, width, height, name}. The url is a
1300+
Meta CDN link you can fetch directly to view the image — no need
1301+
to call any other tool right after upload.
12631302
"""
12641303
# Check required parameters
12651304
if not account_id:
@@ -1832,13 +1871,19 @@ async def create_ad_creative(
18321871
"""
18331872
Create a new ad creative using an uploaded image hash, video ID, or an existing post.
18341873
1835-
Supports five creative modes:
1874+
Supports six creative modes:
18361875
- **Existing post**: Provide object_story_id (format: {page_id}_{post_id}) to promote an existing
18371876
organic or published post. No image_hash or video_id required. Optionally combine with
18381877
asset_customization_rules to attach a 9:16 video for Story/Reels placements.
18391878
- **Simple image/video**: Single image_hash or video_id with object_story_spec
18401879
- **Multi-variant copy**: Use plural text params (messages[], headlines[], descriptions[]) to test
18411880
multiple text variants with a single image/video. No optimization_type or is_dynamic_creative needed.
1881+
- **Placement Asset Customization (dual-aspect, non-DC)**: Serve different aspect ratios per placement
1882+
on a STANDARD ad set without is_dynamic_creative and without the one-ad-per-ad-set cap. Set
1883+
optimization_type="PLACEMENT" and pass videos=[{video_id, label}, ...] (or images=[{image_hash,
1884+
label}, ...]) together with asset_customization_rules whose customization_spec references those
1885+
labels via video_label/image_label. Every label in the rules MUST appear on a videos[]/images[]
1886+
entry, or Meta returns error_subcode=1487390 ("Adcreative Create Failed").
18421887
- **Dynamic Creative**: Multiple variants with dynamic_creative_spec (requires is_dynamic_creative on ad set)
18431888
- **FLEX/DOF (Advantage+)**: Set optimization_type="DEGREES_OF_FREEDOM" for Meta to auto-optimize
18441889
across all asset combinations without requiring is_dynamic_creative on the ad set

0 commit comments

Comments
 (0)