Skip to content

Commit 15bddc3

Browse files
authored
fix(insights): keep leads/CPL on platform_position breakdown (#141)
* fix(insights): keep action data on platform_position breakdown (bump 1.0.117) get_insights with breakdown=platform_position was dropping the action-typed fields (actions, action_values, conversions, cost_per_action_type), so placement-level reports lost leads, CPL, and conversions while spend/clicks/impressions came through. Root cause: the Meta (#100) "(action_type, platform_position) is invalid" error is caused solely by platform_position not being paired with publisher_platform. The tool already auto-adds publisher_platform; once paired, Meta returns the action-typed fields per placement (verified live against the Graph API with action_breakdowns default, [], and [action_type]). Stripping the fields was an unnecessary over-correction. Removes the field-stripping path and its constants; adds regression tests. * chore: sync server.json version to 1.0.117
1 parent b996a6a commit 15bddc3

5 files changed

Lines changed: 95 additions & 28 deletions

File tree

‎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.116"
9+
__version__ = "1.0.117"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/insights.py‎

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@
2828
)
2929

3030

31-
# Breakdowns Meta rejects when combined with the default action_breakdowns
32-
# (which is [action_type]). Picking any of these auto-drops the action-typed
33-
# fields below so the request still succeeds. Meta error path is
34-
# "(#100) Current combination of data breakdown columns (action_type, X) is invalid".
35-
_BREAKDOWNS_INCOMPATIBLE_WITH_ACTION_TYPE = frozenset({
36-
"platform_position",
37-
})
38-
3931
# Breakdowns that collide with the default action_breakdowns=[action_type] but
4032
# are real Meta fields (so we explicitly clear action_breakdowns instead of
4133
# dropping the action-typed response fields). For non-DCO ads this returns
@@ -44,13 +36,6 @@
4436
"media_type",
4537
})
4638

47-
_ACTION_TYPED_FIELDS = frozenset({
48-
"actions",
49-
"action_values",
50-
"cost_per_action_type",
51-
"conversions",
52-
})
53-
5439

5540
def _strip_redundant_actions(row: dict) -> dict:
5641
"""Remove redundant action-type entries from a single insight row."""
@@ -97,10 +82,10 @@ async def get_insights(object_id: str = "", access_token: Optional[str] = None,
9782
Demographic: age, gender, country, region, dma
9883
Platform/Device: device_platform, platform_position, publisher_platform, impression_device
9984
NOTE: platform_position is a Meta-restricted breakdown — Meta requires it to be paired
100-
with publisher_platform and is incompatible with the default action_breakdowns=[action_type].
101-
When you pass platform_position, this tool auto-adds publisher_platform and drops the
102-
action-typed fields (actions, action_values, conversions, cost_per_action_type) from the
103-
response. Use publisher_platform alone if you need action data alongside placement.
85+
with publisher_platform (otherwise "(#100) ... (action_type, platform_position) is invalid").
86+
When you pass platform_position, this tool auto-adds publisher_platform, and the
87+
action-typed fields (actions, action_values, conversions, cost_per_action_type) are
88+
returned per placement, so you get leads/CPL/conversions broken down by placement.
10489
Creative Assets: ad_format_asset, body_asset, call_to_action_asset, description_asset,
10590
image_asset, link_url_asset, title_asset, video_asset, media_type,
10691
creative_relaxation_asset_type, flexible_format_asset_type,
@@ -168,17 +153,17 @@ async def get_insights(object_id: str = "", access_token: Optional[str] = None,
168153
"unique_clicks", "cost_per_action_type",
169154
]
170155

171-
# Meta rejects platform_position alone or with the default
172-
# action_breakdowns=[action_type]: it must be paired with publisher_platform,
173-
# and the action-typed fields must be dropped. Auto-fix both so the request
174-
# succeeds with placement-level metrics.
156+
# Meta rejects platform_position on its own: it must be paired with
157+
# publisher_platform, otherwise "(#100) Current combination of data breakdown
158+
# columns (action_type, platform_position) is invalid". Auto-add
159+
# publisher_platform so the request succeeds with placement-level metrics —
160+
# including the action-typed fields (actions, cost_per_action_type, etc.),
161+
# which Meta returns per placement once the pairing is in place.
175162
breakdown_values = [b.strip() for b in breakdown.split(",") if b.strip()] if breakdown else []
176163
breakdown_set = set(breakdown_values)
177164
if "platform_position" in breakdown_set and "publisher_platform" not in breakdown_set:
178165
breakdown_values = ["publisher_platform", *breakdown_values]
179166
breakdown_set.add("publisher_platform")
180-
if breakdown_set & _BREAKDOWNS_INCOMPATIBLE_WITH_ACTION_TYPE:
181-
fields = [f for f in fields if f not in _ACTION_TYPED_FIELDS]
182167
# media_type collides with action_breakdowns=[action_type] but is a real
183168
# field — override action_breakdowns to empty so the request succeeds with
184169
# the action-typed metrics intact.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "meta-ads-mcp"
7-
version = "1.0.116"
7+
version = "1.0.117"
88
description = "Model Context Protocol (MCP) server for Meta Ads - Use Remote MCP at pipeboard.co for easiest setup"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎server.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "co.pipeboard/meta-ads-mcp",
44
"description": "Facebook / Meta Ads automation with AI: analyze performance, test creatives, optimize spend.",
5-
"version": "1.0.116",
5+
"version": "1.0.117",
66
"remotes": [
77
{
88
"type": "streamable-http",
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Regression tests for get_insights breakdown=platform_position.
2+
3+
platform_position is a Meta-restricted breakdown: it must be paired with
4+
publisher_platform or Meta returns "(#100) Current combination of data
5+
breakdown columns (action_type, platform_position) is invalid". Once paired,
6+
Meta DOES return the action-typed fields (actions, cost_per_action_type,
7+
conversions) per placement.
8+
9+
A previous version of get_insights worked around the (#100) error by dropping
10+
the action-typed fields entirely, which made leads/CPL/conversions disappear
11+
from placement-level reports. These tests pin the corrected behavior: auto-pair
12+
publisher_platform AND keep the action-typed fields.
13+
"""
14+
15+
import json
16+
import pytest
17+
from unittest.mock import AsyncMock, patch
18+
from meta_ads_mcp.core.insights import get_insights
19+
20+
21+
@pytest.fixture
22+
def mock_auth_manager():
23+
with patch('meta_ads_mcp.core.api.auth_manager') as mock, \
24+
patch('meta_ads_mcp.core.auth.get_current_access_token') as mock_get_token:
25+
mock.get_current_access_token.return_value = "test_access_token"
26+
mock.is_token_valid.return_value = True
27+
mock.app_id = "test_app_id"
28+
mock_get_token.return_value = "test_access_token"
29+
yield mock
30+
31+
32+
async def _call_and_get_params(breakdown, **kwargs):
33+
"""Invoke get_insights with a mocked API and return the params dict sent to Meta."""
34+
with patch('meta_ads_mcp.core.insights.make_api_request', new_callable=AsyncMock) as mock_api:
35+
mock_api.return_value = {"data": []}
36+
await get_insights(
37+
object_id="act_701351919139047",
38+
level="account",
39+
time_range="last_30d",
40+
breakdown=breakdown,
41+
**kwargs,
42+
)
43+
mock_api.assert_called_once()
44+
return mock_api.call_args[0][2]
45+
46+
47+
class TestPlatformPositionBreakdown:
48+
@pytest.mark.asyncio
49+
async def test_platform_position_keeps_action_typed_fields(self, mock_auth_manager):
50+
"""The core regression: action-typed fields must NOT be stripped."""
51+
params = await _call_and_get_params("platform_position")
52+
fields = params["fields"].split(",")
53+
for f in ("actions", "action_values", "conversions", "cost_per_action_type"):
54+
assert f in fields, f"{f} was stripped from fields for platform_position"
55+
56+
@pytest.mark.asyncio
57+
async def test_platform_position_auto_pairs_publisher_platform(self, mock_auth_manager):
58+
"""publisher_platform is auto-prepended so Meta does not reject the request."""
59+
params = await _call_and_get_params("platform_position")
60+
breakdowns = params["breakdowns"].split(",")
61+
assert "publisher_platform" in breakdowns
62+
assert "platform_position" in breakdowns
63+
64+
@pytest.mark.asyncio
65+
async def test_platform_position_does_not_force_action_breakdowns(self, mock_auth_manager):
66+
"""No explicit action_breakdowns is sent — Meta's default action_type slicing
67+
applies, which returns actions per placement (verified live against Meta)."""
68+
params = await _call_and_get_params("platform_position")
69+
assert "action_breakdowns" not in params
70+
71+
@pytest.mark.asyncio
72+
async def test_caller_action_breakdowns_still_wins(self, mock_auth_manager):
73+
"""An explicit action_breakdowns from the caller is still respected."""
74+
params = await _call_and_get_params("platform_position", action_breakdowns=[])
75+
assert params["action_breakdowns"] == "[]"
76+
77+
@pytest.mark.asyncio
78+
async def test_already_paired_breakdown_is_not_duplicated(self, mock_auth_manager):
79+
"""Passing publisher_platform,platform_position does not duplicate publisher_platform."""
80+
params = await _call_and_get_params("publisher_platform,platform_position")
81+
breakdowns = params["breakdowns"].split(",")
82+
assert breakdowns.count("publisher_platform") == 1

0 commit comments

Comments
 (0)