Skip to content

Commit e4f872b

Browse files
authored
fix(auth): distinguish policy-blocked errors from token expiry (1.0.113) (#134)
* fix(auth): distinguish policy-blocked errors from token expiry (1.0.113) Code 368 (action disallowed) and code 190 + subcode 459 (user checkpointed by Facebook security) leave the access token valid; invalidating it and surfacing a renew-token message is misleading. - Skip auth_manager.invalidate_token() for those two cases. - Add is_account_disabled flag on the error payload with the original error_code and error_subcode so callers can branch on it instead of treating every auth failure as a stale token. - Other code 190 subcodes (458, 460, 463, 464, 467) remain genuine session errors and keep the existing invalidate + reauth path. * chore: bump server.json to 1.0.113 to match pyproject.toml
1 parent 24032e2 commit e4f872b

5 files changed

Lines changed: 293 additions & 19 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.112"
9+
__version__ = "1.0.113"
1010

1111
__all__ = [
1212
'get_ad_accounts',

‎meta_ads_mcp/core/api.py‎

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -71,22 +71,58 @@ def ensure_act_prefix(account_id: str) -> str:
7171
logger.info(f"META_APP_ID env var present: {'Yes' if os.environ.get('META_APP_ID') else 'No'}")
7272
logger.info(f"META_APP_SECRET env var present (appsecret_proof will be {'enabled' if os.environ.get('META_APP_SECRET') else 'disabled'})")
7373

74+
def _is_account_disabled_error(error_code: Any, error_subcode: Any) -> bool:
75+
"""Return True when a Graph error indicates the ad account / action is
76+
blocked by Meta policy rather than the token being invalid.
77+
78+
Currently covers:
79+
- code 368: "The action attempted has been deemed abusive or is otherwise
80+
disallowed." Token is still valid; the specific action was rejected.
81+
- code 190 + subcode 459: user has been checkpointed by Facebook
82+
security. Token is not expired; the user must clear the checkpoint on
83+
Facebook before further calls succeed. Telling them to reconnect on
84+
Pipeboard does not unblock them.
85+
86+
Other code 190 subcodes (458, 460, 463, 464, 467) are genuine session /
87+
credential errors — keep the existing invalidate-token behavior for those.
88+
"""
89+
if error_code == 368:
90+
return True
91+
if error_code == 190 and error_subcode == 459:
92+
return True
93+
return False
94+
95+
7496
class GraphAPIError(Exception):
7597
"""Exception raised for errors from the Graph API."""
7698
def __init__(self, error_data: Dict[str, Any]):
7799
self.error_data = error_data
78100
self.message = error_data.get('message', 'Unknown Graph API error')
79101
super().__init__(self.message)
80-
102+
81103
# Log error details
82104
logger.error(f"Graph API Error: {self.message}")
83105
logger.debug(f"Error details: {error_data}")
84-
106+
107+
code = error_data.get("code")
108+
subcode = error_data.get("error_subcode")
109+
85110
# Check if this is an auth error (code 4 is rate limiting, NOT auth)
86-
if "code" in error_data and error_data["code"] in [190, 102]:
87-
logger.warning(f"Auth error detected (code: {error_data['code']}). Invalidating token.")
88-
auth_manager.invalidate_token()
89-
elif "code" in error_data and error_data["code"] == 4:
111+
if code in [190, 102]:
112+
if _is_account_disabled_error(code, subcode):
113+
logger.warning(
114+
f"Account/action policy block (code={code}, subcode={subcode}). "
115+
f"Token is still valid — NOT invalidating."
116+
)
117+
else:
118+
logger.warning(f"Auth error detected (code: {code}). Invalidating token.")
119+
auth_manager.invalidate_token()
120+
elif code == 368:
121+
logger.warning(
122+
f"Action disallowed (code=368, subcode={subcode}). "
123+
f"Token is still valid — NOT invalidating."
124+
)
125+
elif code == 4:
90126
logger.warning(f"Rate limit error detected (code: 4, subcode: {error_data.get('error_subcode', 'N/A')}). Token is still valid — NOT invalidating.")
91127

92128

@@ -258,17 +294,31 @@ async def make_api_request(
258294

259295
# Check for rate limit errors vs authentication errors.
260296
# Code 4 is a rate limit (NOT auth) — do NOT invalidate token.
297+
error_code = None
298+
error_subcode = None
299+
is_account_disabled = False
261300
if "error" in error_info:
262301
error_obj = error_info.get("error", {})
263-
error_code = error_obj.get("code") if isinstance(error_obj, dict) else None
302+
if isinstance(error_obj, dict):
303+
error_code = error_obj.get("code")
304+
error_subcode = error_obj.get("error_subcode")
264305

265306
if error_code == 4:
266307
# Application-level rate limit — token is still valid
267308
logger.warning(
268-
f"Facebook API rate limit (code=4, subcode={error_obj.get('error_subcode', 'N/A')}, "
309+
f"Facebook API rate limit (code=4, subcode={error_subcode}, "
269310
f"msg={error_obj.get('error_user_msg', error_obj.get('message', 'N/A'))}). "
270311
f"Token is still valid — NOT invalidating."
271312
)
313+
elif _is_account_disabled_error(error_code, error_subcode):
314+
# Policy-side block (account or action). Token is still
315+
# valid; surface a distinct flag so callers can branch
316+
# without treating this as a stale-token reconnect.
317+
is_account_disabled = True
318+
logger.warning(
319+
f"Account/action policy block (code={error_code}, subcode={error_subcode}). "
320+
f"Token is still valid — NOT invalidating."
321+
)
272322
elif error_code in [190, 102, 200, 10]:
273323
logger.warning(f"Detected Facebook API auth error: {error_code}")
274324
if error_code == 200 and "Provide valid app ID" in error_obj.get("message", ""):
@@ -288,7 +338,7 @@ async def make_api_request(
288338
elif e.response.status_code in [401, 403]:
289339
logger.warning(f"Detected authentication error ({e.response.status_code})")
290340
auth_manager.invalidate_token()
291-
341+
292342
# Include full details for technical users. URLs are scrubbed of
293343
# access_token/appsecret_proof — see GHSA-9gw6-46qc-99vr.
294344
full_response = {
@@ -299,15 +349,20 @@ async def make_api_request(
299349
"request_method": e.request.method,
300350
"request_url": _redact_url(str(e.request.url))
301351
}
302-
352+
303353
# Return a properly structured error object
304-
return {
305-
"error": {
306-
"message": f"HTTP Error: {e.response.status_code}",
307-
"details": error_info,
308-
"full_response": full_response
309-
}
354+
error_payload: Dict[str, Any] = {
355+
"message": f"HTTP Error: {e.response.status_code}",
356+
"details": error_info,
357+
"full_response": full_response,
310358
}
359+
if is_account_disabled:
360+
error_payload["is_account_disabled"] = True
361+
if error_code is not None:
362+
error_payload["error_code"] = error_code
363+
if error_subcode is not None:
364+
error_payload["error_subcode"] = error_subcode
365+
return {"error": error_payload}
311366

312367
except Exception as e:
313368
logger.error(f"Request Error: {str(e)}")

‎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.112"
7+
version = "1.0.113"
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.112",
5+
"version": "1.0.113",
66
"remotes": [
77
{
88
"type": "streamable-http",
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Tests for distinguishing policy-blocked errors from token-expiry errors.
2+
3+
Meta returns code 368 ("action disallowed") and code 190 / subcode 459
4+
("user checkpointed") for cases where the access token is still valid but
5+
the requested action / account is restricted. The library must NOT
6+
invalidate the token in those cases and MUST surface a distinct
7+
is_account_disabled flag so callers can branch on it.
8+
9+
Other code 190 subcodes (458, 460, 463, 464, 467) are genuine session
10+
errors and keep the existing invalidate-token + reauth path.
11+
"""
12+
13+
import httpx
14+
import pytest
15+
from unittest.mock import AsyncMock, MagicMock, patch
16+
17+
from meta_ads_mcp.core import api as api_module
18+
from meta_ads_mcp.core.api import (
19+
GraphAPIError,
20+
_is_account_disabled_error,
21+
make_api_request,
22+
)
23+
24+
25+
class TestIsAccountDisabledError:
26+
"""Pure helper — covers the code/subcode classification."""
27+
28+
def test_code_368_is_account_disabled(self):
29+
assert _is_account_disabled_error(368, None) is True
30+
assert _is_account_disabled_error(368, 1404082) is True
31+
32+
def test_code_190_subcode_459_is_account_disabled(self):
33+
assert _is_account_disabled_error(190, 459) is True
34+
35+
@pytest.mark.parametrize("subcode", [458, 460, 463, 464, 467])
36+
def test_code_190_other_subcodes_are_not_account_disabled(self, subcode):
37+
# Genuine session errors — keep the reauth path
38+
assert _is_account_disabled_error(190, subcode) is False
39+
40+
def test_code_190_no_subcode_is_not_account_disabled(self):
41+
assert _is_account_disabled_error(190, None) is False
42+
43+
def test_unrelated_codes_are_not_account_disabled(self):
44+
assert _is_account_disabled_error(4, None) is False
45+
assert _is_account_disabled_error(102, None) is False
46+
assert _is_account_disabled_error(100, 33) is False
47+
assert _is_account_disabled_error(None, None) is False
48+
49+
50+
class TestGraphAPIError:
51+
"""The exception type checks codes at __init__ time and decides whether
52+
to invalidate the token."""
53+
54+
def test_code_368_does_not_invalidate_token(self):
55+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv:
56+
GraphAPIError({
57+
"code": 368,
58+
"message": "The action attempted has been deemed abusive",
59+
})
60+
mock_inv.assert_not_called()
61+
62+
def test_code_190_subcode_459_does_not_invalidate_token(self):
63+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv:
64+
GraphAPIError({
65+
"code": 190,
66+
"error_subcode": 459,
67+
"message": "User has been checkpointed",
68+
})
69+
mock_inv.assert_not_called()
70+
71+
def test_code_190_subcode_463_still_invalidates_token(self):
72+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv:
73+
GraphAPIError({
74+
"code": 190,
75+
"error_subcode": 463,
76+
"message": "Session has expired",
77+
})
78+
mock_inv.assert_called_once()
79+
80+
def test_code_190_no_subcode_still_invalidates_token(self):
81+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv:
82+
GraphAPIError({
83+
"code": 190,
84+
"message": "Error validating access token",
85+
})
86+
mock_inv.assert_called_once()
87+
88+
def test_code_4_does_not_invalidate_token(self):
89+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv:
90+
GraphAPIError({
91+
"code": 4,
92+
"error_subcode": 1504022,
93+
"message": "Application request limit reached",
94+
})
95+
mock_inv.assert_not_called()
96+
97+
98+
def _build_http_error(status: int, body: dict) -> httpx.HTTPStatusError:
99+
request = httpx.Request("GET", "https://graph.facebook.com/v24.0/act_123/insights")
100+
response = httpx.Response(status, request=request, json=body)
101+
return httpx.HTTPStatusError("error", request=request, response=response)
102+
103+
104+
class TestMakeApiRequestHttpErrors:
105+
"""End-to-end on the HTTP error handler: account-disabled errors flow
106+
through with the is_account_disabled flag, token errors trigger
107+
invalidate_token and DO NOT set the flag."""
108+
109+
@pytest.mark.asyncio
110+
async def test_code_368_returns_is_account_disabled_flag(self):
111+
body = {
112+
"error": {
113+
"message": "The action attempted has been deemed abusive",
114+
"type": "OAuthException",
115+
"code": 368,
116+
"error_subcode": 1404082,
117+
"fbtrace_id": "abc",
118+
}
119+
}
120+
http_err = _build_http_error(400, body)
121+
122+
client = MagicMock()
123+
client.__aenter__ = AsyncMock(return_value=client)
124+
client.__aexit__ = AsyncMock(return_value=False)
125+
client.get = AsyncMock(side_effect=http_err)
126+
127+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv, \
128+
patch.object(api_module, "httpx") as mock_httpx:
129+
mock_httpx.AsyncClient = MagicMock(return_value=client)
130+
mock_httpx.HTTPStatusError = httpx.HTTPStatusError
131+
result = await make_api_request("act_123/insights", "tok")
132+
133+
assert isinstance(result, dict)
134+
assert "error" in result
135+
assert result["error"].get("is_account_disabled") is True
136+
assert result["error"].get("error_code") == 368
137+
assert result["error"].get("error_subcode") == 1404082
138+
# Original Meta error details preserved
139+
assert result["error"]["details"]["error"]["code"] == 368
140+
mock_inv.assert_not_called()
141+
142+
@pytest.mark.asyncio
143+
async def test_code_190_subcode_459_returns_is_account_disabled_flag(self):
144+
body = {
145+
"error": {
146+
"message": "User has been checkpointed",
147+
"type": "OAuthException",
148+
"code": 190,
149+
"error_subcode": 459,
150+
}
151+
}
152+
http_err = _build_http_error(400, body)
153+
154+
client = MagicMock()
155+
client.__aenter__ = AsyncMock(return_value=client)
156+
client.__aexit__ = AsyncMock(return_value=False)
157+
client.get = AsyncMock(side_effect=http_err)
158+
159+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv, \
160+
patch.object(api_module, "httpx") as mock_httpx:
161+
mock_httpx.AsyncClient = MagicMock(return_value=client)
162+
mock_httpx.HTTPStatusError = httpx.HTTPStatusError
163+
result = await make_api_request("act_123/insights", "tok")
164+
165+
assert result["error"].get("is_account_disabled") is True
166+
assert result["error"].get("error_code") == 190
167+
assert result["error"].get("error_subcode") == 459
168+
mock_inv.assert_not_called()
169+
170+
@pytest.mark.asyncio
171+
async def test_code_190_subcode_463_does_not_set_flag_and_invalidates(self):
172+
body = {
173+
"error": {
174+
"message": "Session has expired",
175+
"type": "OAuthException",
176+
"code": 190,
177+
"error_subcode": 463,
178+
}
179+
}
180+
http_err = _build_http_error(400, body)
181+
182+
client = MagicMock()
183+
client.__aenter__ = AsyncMock(return_value=client)
184+
client.__aexit__ = AsyncMock(return_value=False)
185+
client.get = AsyncMock(side_effect=http_err)
186+
187+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv, \
188+
patch.object(api_module, "httpx") as mock_httpx:
189+
mock_httpx.AsyncClient = MagicMock(return_value=client)
190+
mock_httpx.HTTPStatusError = httpx.HTTPStatusError
191+
result = await make_api_request("act_123/insights", "tok")
192+
193+
assert "is_account_disabled" not in result["error"]
194+
mock_inv.assert_called_once()
195+
196+
@pytest.mark.asyncio
197+
async def test_code_190_no_subcode_does_not_set_flag_and_invalidates(self):
198+
body = {
199+
"error": {
200+
"message": "Error validating access token",
201+
"type": "OAuthException",
202+
"code": 190,
203+
}
204+
}
205+
http_err = _build_http_error(400, body)
206+
207+
client = MagicMock()
208+
client.__aenter__ = AsyncMock(return_value=client)
209+
client.__aexit__ = AsyncMock(return_value=False)
210+
client.get = AsyncMock(side_effect=http_err)
211+
212+
with patch.object(api_module.auth_manager, "invalidate_token") as mock_inv, \
213+
patch.object(api_module, "httpx") as mock_httpx:
214+
mock_httpx.AsyncClient = MagicMock(return_value=client)
215+
mock_httpx.HTTPStatusError = httpx.HTTPStatusError
216+
result = await make_api_request("act_123/insights", "tok")
217+
218+
assert "is_account_disabled" not in result["error"]
219+
mock_inv.assert_called_once()

0 commit comments

Comments
 (0)