meta-ads-mcp 0.4.1__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/ads.py +9 -8
  4. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/duplication.py +38 -6
  5. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/http_auth_integration.py +66 -7
  6. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/pyproject.toml +1 -1
  7. meta_ads_mcp-0.4.3/tests/test_duplication.py +244 -0
  8. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/tests/test_duplication_regression.py +157 -104
  9. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/tests/test_http_transport.py +72 -1
  10. meta_ads_mcp-0.4.1/tests/test_duplication.py +0 -136
  11. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/.github/workflows/publish.yml +0 -0
  12. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/.github/workflows/test.yml +0 -0
  13. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/.gitignore +0 -0
  14. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/CUSTOM_META_APP.md +0 -0
  15. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/Dockerfile +0 -0
  16. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/LICENSE +0 -0
  17. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/LOCAL_INSTALLATION.md +0 -0
  18. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/META_API_NOTES.md +0 -0
  19. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/README.md +0 -0
  20. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/RELEASE.md +0 -0
  21. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/STREAMABLE_HTTP_SETUP.md +0 -0
  22. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/examples/README.md +0 -0
  23. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/examples/example_http_client.py +0 -0
  24. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/future_improvements.md +0 -0
  25. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/images/meta-ads-example.png +0 -0
  26. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_auth.sh +0 -0
  27. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/__main__.py +0 -0
  28. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/__init__.py +0 -0
  29. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/accounts.py +0 -0
  30. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/ads_library.py +0 -0
  31. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/adsets.py +0 -0
  32. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/api.py +0 -0
  33. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/auth.py +0 -0
  34. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/authentication.py +0 -0
  35. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/budget_schedules.py +0 -0
  36. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/callback_server.py +0 -0
  37. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/campaigns.py +0 -0
  38. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/insights.py +0 -0
  39. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  40. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/reports.py +0 -0
  41. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/resources.py +0 -0
  42. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/server.py +0 -0
  43. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/meta_ads_mcp/core/utils.py +0 -0
  44. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/requirements.txt +0 -0
  45. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/setup.py +0 -0
  46. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/smithery.yaml +0 -0
  47. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/tests/README.md +0 -0
  48. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/tests/README_REGRESSION_TESTS.md +0 -0
  49. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/tests/__init__.py +0 -0
  50. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/tests/conftest.py +0 -0
  51. {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.3}/tests/test_openai.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Model Context Protocol (MCP) plugin for interacting with Meta Ads API
5
5
  Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
6
6
  Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
@@ -7,7 +7,7 @@ with the Claude LLM.
7
7
 
8
8
  from meta_ads_mcp.core.server import main
9
9
 
10
- __version__ = "0.4.1"
10
+ __version__ = "0.4.3"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -38,26 +38,27 @@ async def get_ads(access_token: str = None, account_id: str = None, limit: int =
38
38
  else:
39
39
  return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
40
40
 
41
+ # Prioritize adset_id over campaign_id - use adset-specific endpoint
42
+ if adset_id:
43
+ endpoint = f"{adset_id}/ads"
44
+ params = {
45
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
46
+ "limit": limit
47
+ }
41
48
  # Use campaign-specific endpoint if campaign_id is provided
42
- if campaign_id:
49
+ elif campaign_id:
43
50
  endpoint = f"{campaign_id}/ads"
44
51
  params = {
45
52
  "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
46
53
  "limit": limit
47
54
  }
48
- # Adset ID can still be used to filter within the campaign
49
- if adset_id:
50
- params["adset_id"] = adset_id
51
55
  else:
52
- # Default to account-level endpoint if no campaign_id
56
+ # Default to account-level endpoint if no specific filters
53
57
  endpoint = f"{account_id}/ads"
54
58
  params = {
55
59
  "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
56
60
  "limit": limit
57
61
  }
58
- # Adset ID can filter at the account level if no campaign specified
59
- if adset_id:
60
- params["adset_id"] = adset_id
61
62
 
62
63
  data = await make_api_request(endpoint, access_token, params)
63
64
 
@@ -6,6 +6,8 @@ import httpx
6
6
  from typing import Optional, Dict, Any, List, Union
7
7
  from .server import mcp_server
8
8
  from .api import meta_api_tool
9
+ from .auth import get_current_access_token
10
+ from .http_auth_integration import FastMCPAuthIntegration
9
11
 
10
12
 
11
13
  # Only register the duplication functions if the environment variable is set
@@ -178,21 +180,50 @@ if ENABLE_DUPLICATION:
178
180
 
179
181
  async def _forward_duplication_request(resource_type: str, resource_id: str, access_token: str, options: Dict[str, Any]) -> str:
180
182
  """
181
- Forward duplication request to the cloud-hosted MCP API.
183
+ Forward duplication request to the cloud-hosted MCP API using dual-header authentication.
184
+
185
+ This implements the dual-header authentication pattern for MCP server callbacks:
186
+ - Authorization: Bearer <facebook_token> - Facebook access token for Meta API calls
187
+ - X-Pipeboard-Token: <pipeboard_token> - Pipeboard API token for authentication
182
188
 
183
189
  Args:
184
190
  resource_type: Type of resource to duplicate (campaign, adset, ad, creative)
185
191
  resource_id: ID of the resource to duplicate
186
- access_token: Meta API access token from the request
192
+ access_token: Meta API access token (optional, will use context if not provided)
187
193
  options: Duplication options
188
194
  """
189
195
  try:
190
- if not access_token:
196
+ # Get tokens from the request context that were set by the HTTP auth middleware
197
+ # In the dual-header authentication pattern:
198
+ # - Pipeboard token comes from X-Pipeboard-Token header (for authentication)
199
+ # - Facebook token comes from Authorization header (for Meta API calls)
200
+
201
+ # Get tokens from context set by AuthInjectionMiddleware
202
+ pipeboard_token = FastMCPAuthIntegration.get_pipeboard_token()
203
+ facebook_token = FastMCPAuthIntegration.get_auth_token()
204
+
205
+ # Use provided access_token parameter if no Facebook token found in context
206
+ if not facebook_token:
207
+ facebook_token = access_token if access_token else await get_current_access_token()
208
+
209
+ # Validate we have both required tokens
210
+ if not pipeboard_token:
211
+ return json.dumps({
212
+ "error": "authentication_required",
213
+ "message": "Pipeboard API token not found",
214
+ "details": {
215
+ "required": "Valid Pipeboard token via X-Pipeboard-Token header",
216
+ "received_headers": "Check that the MCP server is forwarding the X-Pipeboard-Token header"
217
+ }
218
+ }, indent=2)
219
+
220
+ if not facebook_token:
191
221
  return json.dumps({
192
222
  "error": "authentication_required",
193
223
  "message": "Meta Ads access token not found",
194
224
  "details": {
195
- "required": "Valid access token from authenticated session"
225
+ "required": "Valid Meta access token from authenticated session",
226
+ "check": "Ensure Facebook account is connected and token is valid"
196
227
  }
197
228
  }, indent=2)
198
229
 
@@ -200,9 +231,10 @@ async def _forward_duplication_request(resource_type: str, resource_id: str, acc
200
231
  base_url = "https://mcp.pipeboard.co"
201
232
  endpoint = f"{base_url}/api/meta/duplicate/{resource_type}/{resource_id}"
202
233
 
203
- # Prepare the request
234
+ # Prepare the dual-header authentication as per API documentation
204
235
  headers = {
205
- "Authorization": f"Bearer {access_token}",
236
+ "Authorization": f"Bearer {facebook_token}", # Facebook token for Meta API
237
+ "X-Pipeboard-Token": pipeboard_token, # Pipeboard token for auth
206
238
  "Content-Type": "application/json",
207
239
  "User-Agent": "meta-ads-mcp/1.0"
208
240
  }
@@ -13,6 +13,7 @@ import json
13
13
 
14
14
  # Use context variables instead of thread-local storage for better async support
15
15
  _auth_token: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar('auth_token', default=None)
16
+ _pipeboard_token: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar('pipeboard_token', default=None)
16
17
 
17
18
  class FastMCPAuthIntegration:
18
19
  """Direct integration with FastMCP for HTTP authentication"""
@@ -35,11 +36,34 @@ class FastMCPAuthIntegration:
35
36
  """
36
37
  return _auth_token.get(None)
37
38
 
39
+ @staticmethod
40
+ def set_pipeboard_token(token: str) -> None:
41
+ """Set Pipeboard token for the current context
42
+
43
+ Args:
44
+ token: Pipeboard API token to use for this request
45
+ """
46
+ _pipeboard_token.set(token)
47
+
48
+ @staticmethod
49
+ def get_pipeboard_token() -> Optional[str]:
50
+ """Get Pipeboard token for the current context
51
+
52
+ Returns:
53
+ Pipeboard token if set, None otherwise
54
+ """
55
+ return _pipeboard_token.get(None)
56
+
38
57
  @staticmethod
39
58
  def clear_auth_token() -> None:
40
59
  """Clear authentication token for the current context"""
41
60
  _auth_token.set(None)
42
61
 
62
+ @staticmethod
63
+ def clear_pipeboard_token() -> None:
64
+ """Clear Pipeboard token for the current context"""
65
+ _pipeboard_token.set(None)
66
+
43
67
  @staticmethod
44
68
  def extract_token_from_headers(headers: dict) -> Optional[str]:
45
69
  """Extract token from HTTP headers
@@ -69,6 +93,30 @@ class FastMCPAuthIntegration:
69
93
  return pipeboard_token
70
94
 
71
95
  return None
96
+
97
+ @staticmethod
98
+ def extract_pipeboard_token_from_headers(headers: dict) -> Optional[str]:
99
+ """Extract Pipeboard token from HTTP headers
100
+
101
+ Args:
102
+ headers: HTTP request headers
103
+
104
+ Returns:
105
+ Pipeboard token if found, None otherwise
106
+ """
107
+ # Check for Pipeboard token in X-Pipeboard-Token header (duplication API pattern)
108
+ pipeboard_token = headers.get('X-Pipeboard-Token') or headers.get('x-pipeboard-token')
109
+ if pipeboard_token:
110
+ logger.debug("Found Pipeboard token in X-Pipeboard-Token header")
111
+ return pipeboard_token
112
+
113
+ # Check for legacy Pipeboard token header
114
+ legacy_token = headers.get('X-PIPEBOARD-API-TOKEN') or headers.get('x-pipeboard-api-token')
115
+ if legacy_token:
116
+ logger.debug("Found Pipeboard token in legacy X-PIPEBOARD-API-TOKEN header")
117
+ return legacy_token
118
+
119
+ return None
72
120
 
73
121
  def patch_fastmcp_server(mcp_server):
74
122
  """Patch FastMCP server to inject authentication from HTTP headers
@@ -203,21 +251,32 @@ class AuthInjectionMiddleware(BaseHTTPMiddleware):
203
251
  logger.debug(f"HTTP Auth Middleware: Processing request to {request.url.path}")
204
252
  logger.debug(f"HTTP Auth Middleware: Request headers: {list(request.headers.keys())}")
205
253
 
206
- token = FastMCPAuthIntegration.extract_token_from_headers(dict(request.headers))
254
+ # Extract both types of tokens for dual-header authentication
255
+ auth_token = FastMCPAuthIntegration.extract_token_from_headers(dict(request.headers))
256
+ pipeboard_token = FastMCPAuthIntegration.extract_pipeboard_token_from_headers(dict(request.headers))
207
257
 
208
- if token:
209
- logger.debug(f"HTTP Auth Middleware: Extracted token: {token[:10]}...")
258
+ if auth_token:
259
+ logger.debug(f"HTTP Auth Middleware: Extracted auth token: {auth_token[:10]}...")
210
260
  logger.debug("Injecting auth token into request context")
211
- FastMCPAuthIntegration.set_auth_token(token)
212
- else:
213
- logger.warning("HTTP Auth Middleware: No authentication token found in headers")
261
+ FastMCPAuthIntegration.set_auth_token(auth_token)
262
+
263
+ if pipeboard_token:
264
+ logger.debug(f"HTTP Auth Middleware: Extracted Pipeboard token: {pipeboard_token[:10]}...")
265
+ logger.debug("Injecting Pipeboard token into request context")
266
+ FastMCPAuthIntegration.set_pipeboard_token(pipeboard_token)
267
+
268
+ if not auth_token and not pipeboard_token:
269
+ logger.warning("HTTP Auth Middleware: No authentication tokens found in headers")
214
270
 
215
271
  try:
216
272
  response = await call_next(request)
217
273
  return response
218
274
  finally:
219
- if token: # Clear only if a token was set for this request
275
+ # Clear tokens that were set for this request
276
+ if auth_token:
220
277
  FastMCPAuthIntegration.clear_auth_token()
278
+ if pipeboard_token:
279
+ FastMCPAuthIntegration.clear_pipeboard_token()
221
280
 
222
281
  def setup_starlette_middleware(app):
223
282
  """Add AuthInjectionMiddleware to the Starlette app if not already present.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.4.1"
7
+ version = "0.4.3"
8
8
  description = "Model Context Protocol (MCP) plugin for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,244 @@
1
+ """Tests for the duplication module."""
2
+
3
+ import os
4
+ import json
5
+ import pytest
6
+ from unittest.mock import patch, AsyncMock, Mock
7
+ from meta_ads_mcp.core.duplication import ENABLE_DUPLICATION
8
+
9
+
10
+ def test_duplication_disabled_by_default():
11
+ """Test that duplication is disabled by default."""
12
+ # Test with no environment variable set
13
+ with patch.dict(os.environ, {}, clear=True):
14
+ from meta_ads_mcp.core import duplication
15
+ # When imported fresh, it should be disabled
16
+ assert not duplication.ENABLE_DUPLICATION
17
+
18
+
19
+ def test_duplication_enabled_with_env_var():
20
+ """Test that duplication is enabled when environment variable is set."""
21
+ with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
22
+ # Need to reload the module to pick up the new environment variable
23
+ import importlib
24
+ from meta_ads_mcp.core import duplication
25
+ importlib.reload(duplication)
26
+ assert duplication.ENABLE_DUPLICATION
27
+
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_forward_duplication_request_no_pipeboard_token():
31
+ """Test that _forward_duplication_request handles missing Pipeboard token."""
32
+ from meta_ads_mcp.core.duplication import _forward_duplication_request
33
+
34
+ # Mock the auth integration to return no Pipeboard token but a Facebook token
35
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
36
+ mock_auth.get_pipeboard_token.return_value = None # No Pipeboard token
37
+ mock_auth.get_auth_token.return_value = "facebook_token" # Has Facebook token
38
+
39
+ result = await _forward_duplication_request("campaign", "123456789", None, {})
40
+ result_json = json.loads(result)
41
+
42
+ assert result_json["error"] == "authentication_required"
43
+ assert "Pipeboard API token not found" in result_json["message"]
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_forward_duplication_request_no_facebook_token():
48
+ """Test that _forward_duplication_request handles missing Facebook token."""
49
+ from meta_ads_mcp.core.duplication import _forward_duplication_request
50
+
51
+ # Mock the auth integration to return Pipeboard token but no Facebook token
52
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
53
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token" # Has Pipeboard token
54
+ mock_auth.get_auth_token.return_value = None # No Facebook token
55
+
56
+ # Mock get_current_access_token to also return None
57
+ with patch("meta_ads_mcp.core.duplication.get_current_access_token") as mock_get_token:
58
+ mock_get_token.return_value = None
59
+
60
+ result = await _forward_duplication_request("campaign", "123456789", None, {})
61
+ result_json = json.loads(result)
62
+
63
+ assert result_json["error"] == "authentication_required"
64
+ assert "Meta Ads access token not found" in result_json["message"]
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_forward_duplication_request_with_both_tokens():
69
+ """Test that _forward_duplication_request makes HTTP request with dual headers."""
70
+ from meta_ads_mcp.core.duplication import _forward_duplication_request
71
+
72
+ mock_response = Mock()
73
+ mock_response.status_code = 403
74
+ mock_response.json.return_value = {"error": "premium_feature"}
75
+
76
+ # Mock the auth integration to return both tokens
77
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
78
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
79
+ mock_auth.get_auth_token.return_value = "facebook_token"
80
+
81
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
82
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
83
+
84
+ result = await _forward_duplication_request("campaign", "123456789", None, {
85
+ "name_suffix": " - Test"
86
+ })
87
+ result_json = json.loads(result)
88
+
89
+ # Should return premium feature message for 403 response
90
+ assert result_json["error"] == "premium_feature_required"
91
+ assert "premium feature" in result_json["message"]
92
+
93
+ # Verify the HTTP request was made with correct parameters
94
+ mock_client.return_value.__aenter__.return_value.post.assert_called_once()
95
+ call_args = mock_client.return_value.__aenter__.return_value.post.call_args
96
+
97
+ # Check URL
98
+ assert call_args[0][0] == "https://mcp.pipeboard.co/api/meta/duplicate/campaign/123456789"
99
+
100
+ # Check dual headers (the key change!)
101
+ headers = call_args[1]["headers"]
102
+ assert headers["Authorization"] == "Bearer facebook_token" # Facebook token for Meta API
103
+ assert headers["X-Pipeboard-Token"] == "pipeboard_token" # Pipeboard token for auth
104
+ assert headers["Content-Type"] == "application/json"
105
+
106
+ # Check JSON payload
107
+ json_payload = call_args[1]["json"]
108
+ assert json_payload == {"name_suffix": " - Test"}
109
+
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_forward_duplication_request_with_provided_access_token():
113
+ """Test that provided access_token parameter is used when available."""
114
+ from meta_ads_mcp.core.duplication import _forward_duplication_request
115
+
116
+ mock_response = Mock()
117
+ mock_response.status_code = 200
118
+ mock_response.json.return_value = {"success": True, "new_campaign_id": "987654321"}
119
+
120
+ # Mock the auth integration to return Pipeboard token but no Facebook token in context
121
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
122
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
123
+ mock_auth.get_auth_token.return_value = None # No Facebook token in context
124
+
125
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
126
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
127
+
128
+ # Provide access_token as parameter
129
+ result = await _forward_duplication_request("campaign", "123456789", "provided_facebook_token", {})
130
+ result_json = json.loads(result)
131
+
132
+ # Should succeed
133
+ assert result_json["success"] is True
134
+
135
+ # Verify the HTTP request used the provided token
136
+ call_args = mock_client.return_value.__aenter__.return_value.post.call_args
137
+ headers = call_args[1]["headers"]
138
+ assert headers["Authorization"] == "Bearer provided_facebook_token"
139
+ assert headers["X-Pipeboard-Token"] == "pipeboard_token"
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_duplicate_campaign_function_available_when_enabled():
144
+ """Test that duplicate_campaign function is available when feature is enabled."""
145
+ with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
146
+ # Reload module to pick up environment variable
147
+ import importlib
148
+ from meta_ads_mcp.core import duplication
149
+ importlib.reload(duplication)
150
+
151
+ # Function should be available
152
+ assert hasattr(duplication, 'duplicate_campaign')
153
+
154
+ # Test that it calls the forwarding function
155
+ with patch("meta_ads_mcp.core.duplication._forward_duplication_request") as mock_forward:
156
+ mock_forward.return_value = '{"success": true}'
157
+
158
+ result = await duplication.duplicate_campaign("123456789", access_token="test_token")
159
+
160
+ mock_forward.assert_called_once_with(
161
+ "campaign",
162
+ "123456789",
163
+ "test_token",
164
+ {
165
+ "name_suffix": " - Copy",
166
+ "include_ad_sets": True,
167
+ "include_ads": True,
168
+ "include_creatives": True,
169
+ "copy_schedule": False,
170
+ "new_daily_budget": None,
171
+ "new_status": "PAUSED"
172
+ }
173
+ )
174
+
175
+
176
+ def test_get_estimated_components():
177
+ """Test the _get_estimated_components helper function."""
178
+ from meta_ads_mcp.core.duplication import _get_estimated_components
179
+
180
+ # Test campaign with all components
181
+ campaign_result = _get_estimated_components("campaign", {
182
+ "include_ad_sets": True,
183
+ "include_ads": True,
184
+ "include_creatives": True
185
+ })
186
+ assert campaign_result["campaigns"] == 1
187
+ assert "ad_sets" in campaign_result
188
+ assert "ads" in campaign_result
189
+ assert "creatives" in campaign_result
190
+
191
+ # Test adset
192
+ adset_result = _get_estimated_components("adset", {"include_ads": True})
193
+ assert adset_result["ad_sets"] == 1
194
+ assert "ads" in adset_result
195
+
196
+ # Test creative only
197
+ creative_result = _get_estimated_components("creative", {})
198
+ assert creative_result == {"creatives": 1}
199
+
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_dual_header_authentication_integration():
203
+ """Test that the dual-header authentication works end-to-end."""
204
+ from meta_ads_mcp.core.duplication import _forward_duplication_request
205
+
206
+ mock_response = Mock()
207
+ mock_response.status_code = 200
208
+ mock_response.json.return_value = {
209
+ "success": True,
210
+ "new_campaign_id": "987654321",
211
+ "subscription": {"status": "active"}
212
+ }
213
+
214
+ # Test the complete dual-header flow
215
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
216
+ mock_auth.get_pipeboard_token.return_value = "pb_token_12345"
217
+ mock_auth.get_auth_token.return_value = "fb_token_67890"
218
+
219
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
220
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
221
+
222
+ result = await _forward_duplication_request("adset", "456789", None, {
223
+ "target_campaign_id": "123456",
224
+ "include_ads": True
225
+ })
226
+ result_json = json.loads(result)
227
+
228
+ # Should succeed
229
+ assert result_json["success"] is True
230
+ assert result_json["new_campaign_id"] == "987654321"
231
+
232
+ # Verify correct endpoint was called
233
+ call_args = mock_client.return_value.__aenter__.return_value.post.call_args
234
+ assert "adset/456789" in call_args[0][0]
235
+
236
+ # Verify dual headers were sent correctly
237
+ headers = call_args[1]["headers"]
238
+ assert headers["Authorization"] == "Bearer fb_token_67890"
239
+ assert headers["X-Pipeboard-Token"] == "pb_token_12345"
240
+
241
+ # Verify payload
242
+ payload = call_args[1]["json"]
243
+ assert payload["target_campaign_id"] == "123456"
244
+ assert payload["include_ads"] is True
@@ -131,6 +131,36 @@ class TestDuplicationAPIContract:
131
131
  ]
132
132
 
133
133
  for resource_type, resource_id, expected_url in test_cases:
134
+ # Mock dual-header authentication
135
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
136
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
137
+ mock_auth.get_auth_token.return_value = "facebook_token"
138
+
139
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
140
+ mock_response = AsyncMock()
141
+ mock_response.status_code = 200
142
+ mock_response.json.return_value = {"success": True}
143
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
144
+
145
+ await duplication._forward_duplication_request(
146
+ resource_type, resource_id, "test_token", {}
147
+ )
148
+
149
+ # Verify the correct URL was called
150
+ call_args = mock_client.return_value.__aenter__.return_value.post.call_args
151
+ actual_url = call_args[0][0]
152
+ assert actual_url == expected_url, f"Expected {expected_url}, got {actual_url}"
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_request_headers_format(self, enable_feature):
156
+ """Test that request headers are formatted correctly."""
157
+ duplication = enable_feature
158
+
159
+ # Mock dual-header authentication
160
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
161
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token_12345"
162
+ mock_auth.get_auth_token.return_value = "facebook_token_67890"
163
+
134
164
  with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
135
165
  mock_response = AsyncMock()
136
166
  mock_response.status_code = 200
@@ -138,54 +168,41 @@ class TestDuplicationAPIContract:
138
168
  mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
139
169
 
140
170
  await duplication._forward_duplication_request(
141
- resource_type, resource_id, "test_token", {}
171
+ "campaign", "123456789", "test_token_12345", {"name_suffix": " - Test"}
142
172
  )
143
173
 
144
- # Verify the correct URL was called
174
+ # Verify dual headers are sent correctly
145
175
  call_args = mock_client.return_value.__aenter__.return_value.post.call_args
146
- actual_url = call_args[0][0]
147
- assert actual_url == expected_url, f"Expected {expected_url}, got {actual_url}"
148
-
149
- @pytest.mark.asyncio
150
- async def test_request_headers_format(self, enable_feature):
151
- """Test that request headers are formatted correctly."""
152
- duplication = enable_feature
153
-
154
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
155
- mock_response = AsyncMock()
156
- mock_response.status_code = 200
157
- mock_response.json.return_value = {"success": True}
158
- mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
159
-
160
- await duplication._forward_duplication_request(
161
- "campaign", "123456789", "test_token_12345", {"name_suffix": " - Test"}
162
- )
163
-
164
- # Verify headers
165
- call_args = mock_client.return_value.__aenter__.return_value.post.call_args
166
- headers = call_args[1]["headers"]
167
-
168
- assert headers["Authorization"] == "Bearer test_token_12345"
169
- assert headers["Content-Type"] == "application/json"
170
- assert headers["User-Agent"] == "meta-ads-mcp/1.0"
171
-
176
+ headers = call_args[1]["headers"]
177
+
178
+ # Check the dual-header authentication pattern
179
+ assert headers["Authorization"] == "Bearer facebook_token_67890" # Facebook token for Meta API
180
+ assert headers["X-Pipeboard-Token"] == "pipeboard_token_12345" # Pipeboard token for auth
181
+ assert headers["Content-Type"] == "application/json"
182
+ assert headers["User-Agent"] == "meta-ads-mcp/1.0"
183
+
172
184
  @pytest.mark.asyncio
173
185
  async def test_request_timeout_configuration(self, enable_feature):
174
186
  """Test that request timeout is configured correctly."""
175
187
  duplication = enable_feature
176
188
 
177
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
178
- mock_response = AsyncMock()
179
- mock_response.status_code = 200
180
- mock_response.json.return_value = {"success": True}
181
- mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
182
-
183
- await duplication._forward_duplication_request(
184
- "campaign", "123456789", "test_token", {}
185
- )
189
+ # Mock dual-header authentication
190
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
191
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
192
+ mock_auth.get_auth_token.return_value = "facebook_token"
186
193
 
187
- # Verify timeout is set to 30 seconds
188
- mock_client.assert_called_once_with(timeout=30.0)
194
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
195
+ mock_response = AsyncMock()
196
+ mock_response.status_code = 200
197
+ mock_response.json.return_value = {"success": True}
198
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
199
+
200
+ await duplication._forward_duplication_request(
201
+ "campaign", "123456789", "test_token", {}
202
+ )
203
+
204
+ # Verify timeout is set to 30 seconds
205
+ mock_client.assert_called_once_with(timeout=30.0)
189
206
 
190
207
 
191
208
  class TestDuplicationErrorHandling:
@@ -202,15 +219,33 @@ class TestDuplicationErrorHandling:
202
219
 
203
220
  @pytest.mark.asyncio
204
221
  async def test_missing_access_token_error(self, enable_feature):
205
- """Test error handling when access token is missing."""
222
+ """Test error handling when authentication tokens are missing."""
206
223
  duplication = enable_feature
207
224
 
208
- result = await duplication._forward_duplication_request("campaign", "123", None, {})
209
- result_json = json.loads(result)
225
+ # Test missing Pipeboard token (primary authentication failure)
226
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
227
+ mock_auth.get_pipeboard_token.return_value = None # No Pipeboard token
228
+ mock_auth.get_auth_token.return_value = "facebook_token" # Has Facebook token
229
+
230
+ result = await duplication._forward_duplication_request("campaign", "123", None, {})
231
+ result_json = json.loads(result)
232
+
233
+ assert result_json["error"] == "authentication_required"
234
+ assert "Pipeboard API token not found" in result_json["message"]
210
235
 
211
- assert result_json["error"] == "authentication_required"
212
- assert "access token not found" in result_json["message"]
213
- assert result_json["details"]["required"] == "Valid access token from authenticated session"
236
+ # Test missing Facebook token (secondary authentication failure)
237
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
238
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token" # Has Pipeboard token
239
+ mock_auth.get_auth_token.return_value = None # No Facebook token
240
+
241
+ with patch("meta_ads_mcp.core.duplication.get_current_access_token") as mock_get_token:
242
+ mock_get_token.return_value = None # No fallback token
243
+
244
+ result = await duplication._forward_duplication_request("campaign", "123", None, {})
245
+ result_json = json.loads(result)
246
+
247
+ assert result_json["error"] == "authentication_required"
248
+ assert "Meta Ads access token not found" in result_json["message"]
214
249
 
215
250
  @pytest.mark.asyncio
216
251
  async def test_http_status_code_handling(self, enable_feature):
@@ -230,7 +265,9 @@ class TestDuplicationErrorHandling:
230
265
  ]
231
266
 
232
267
  for status_code, expected_error_type, response_type in status_code_tests:
233
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
268
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client, \
269
+ patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_pipeboard_token", return_value="test_pipeboard_token"), \
270
+ patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_auth_token", return_value="test_facebook_token"):
234
271
  # Use MagicMock instead of AsyncMock for more predictable behavior
235
272
  mock_response = MagicMock()
236
273
  mock_response.status_code = status_code
@@ -304,7 +341,9 @@ class TestDuplicationErrorHandling:
304
341
  ]
305
342
 
306
343
  for exception, expected_error in network_errors:
307
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
344
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client, \
345
+ patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_pipeboard_token", return_value="test_pipeboard_token"), \
346
+ patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_auth_token", return_value="test_facebook_token"):
308
347
  mock_client.return_value.__aenter__.return_value.post.side_effect = exception
309
348
 
310
349
  result = await duplication._forward_duplication_request(
@@ -332,33 +371,37 @@ class TestDuplicationParameterHandling:
332
371
  """Test that None values are filtered from options."""
333
372
  duplication = enable_feature
334
373
 
335
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
336
- mock_response = AsyncMock()
337
- mock_response.status_code = 200
338
- mock_response.json.return_value = {"success": True}
339
- mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
340
-
341
- # Test with options containing None values
342
- options_with_none = {
343
- "name_suffix": " - Test",
344
- "new_daily_budget": None,
345
- "new_status": "PAUSED",
346
- "new_headline": None,
347
- }
374
+ # Mock dual-header authentication
375
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
376
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
377
+ mock_auth.get_auth_token.return_value = "facebook_token"
348
378
 
349
- await duplication._forward_duplication_request(
350
- "campaign", "123", "token", options_with_none
351
- )
352
-
353
- # Verify None values were filtered out
354
- call_args = mock_client.return_value.__aenter__.return_value.post.call_args
355
- json_payload = call_args[1]["json"]
356
-
357
- expected_payload = {
358
- "name_suffix": " - Test",
359
- "new_status": "PAUSED"
360
- }
361
- assert json_payload == expected_payload
379
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
380
+ mock_response = AsyncMock()
381
+ mock_response.status_code = 200
382
+ mock_response.json.return_value = {"success": True}
383
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
384
+
385
+ # Test with options containing None values
386
+ options_with_none = {
387
+ "name_suffix": " - Test",
388
+ "new_daily_budget": None,
389
+ "new_status": "PAUSED",
390
+ "new_headline": None,
391
+ }
392
+
393
+ await duplication._forward_duplication_request(
394
+ "campaign", "123", "token", options_with_none
395
+ )
396
+
397
+ # Verify None values were filtered out
398
+ call_args = mock_client.return_value.__aenter__.return_value.post.call_args
399
+ json_payload = call_args[1]["json"]
400
+
401
+ assert "name_suffix" in json_payload
402
+ assert "new_status" in json_payload
403
+ assert "new_daily_budget" not in json_payload
404
+ assert "new_headline" not in json_payload
362
405
 
363
406
  @pytest.mark.asyncio
364
407
  async def test_campaign_duplication_parameter_forwarding(self, enable_feature):
@@ -736,44 +779,54 @@ class TestDuplicationRegressionEdgeCases:
736
779
  """Test handling of unicode parameters."""
737
780
  duplication = enable_feature
738
781
 
739
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
740
- mock_response = AsyncMock()
741
- mock_response.status_code = 200
742
- mock_response.json.return_value = {"success": True}
743
- mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
782
+ # Mock dual-header authentication
783
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
784
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
785
+ mock_auth.get_auth_token.return_value = "facebook_token"
744
786
 
745
- # Test with unicode characters
746
- unicode_suffix = " - 复制版本 🚀"
747
- await duplication._forward_duplication_request(
748
- "campaign", "123", "token", {"name_suffix": unicode_suffix}
749
- )
750
-
751
- # Verify unicode is preserved in the request
752
- call_args = mock_client.return_value.__aenter__.return_value.post.call_args
753
- json_payload = call_args[1]["json"]
754
- assert json_payload["name_suffix"] == unicode_suffix
755
-
787
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
788
+ mock_response = AsyncMock()
789
+ mock_response.status_code = 200
790
+ mock_response.json.return_value = {"success": True}
791
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
792
+
793
+ # Test with unicode characters
794
+ unicode_suffix = " - 复制版本 🚀"
795
+ await duplication._forward_duplication_request(
796
+ "campaign", "123", "token", {"name_suffix": unicode_suffix}
797
+ )
798
+
799
+ # Verify unicode is preserved in the request
800
+ call_args = mock_client.return_value.__aenter__.return_value.post.call_args
801
+ json_payload = call_args[1]["json"]
802
+ assert json_payload["name_suffix"] == unicode_suffix
803
+
756
804
  @pytest.mark.asyncio
757
805
  async def test_large_parameter_values(self, enable_feature):
758
806
  """Test handling of large parameter values."""
759
807
  duplication = enable_feature
760
808
 
761
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
762
- mock_response = AsyncMock()
763
- mock_response.status_code = 200
764
- mock_response.json.return_value = {"success": True}
765
- mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
766
-
767
- # Test with very large budget value
768
- large_budget = 999999999.99
769
- await duplication._forward_duplication_request(
770
- "campaign", "123", "token", {"new_daily_budget": large_budget}
771
- )
809
+ # Mock dual-header authentication
810
+ with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
811
+ mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
812
+ mock_auth.get_auth_token.return_value = "facebook_token"
772
813
 
773
- # Verify large values are preserved
774
- call_args = mock_client.return_value.__aenter__.return_value.post.call_args
775
- json_payload = call_args[1]["json"]
776
- assert json_payload["new_daily_budget"] == large_budget
814
+ with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
815
+ mock_response = AsyncMock()
816
+ mock_response.status_code = 200
817
+ mock_response.json.return_value = {"success": True}
818
+ mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
819
+
820
+ # Test with very large budget value
821
+ large_budget = 999999999.99
822
+ await duplication._forward_duplication_request(
823
+ "campaign", "123", "token", {"new_daily_budget": large_budget}
824
+ )
825
+
826
+ # Verify large values are preserved
827
+ call_args = mock_client.return_value.__aenter__.return_value.post.call_args
828
+ json_payload = call_args[1]["json"]
829
+ assert json_payload["new_daily_budget"] == large_budget
777
830
 
778
831
  def test_module_reload_safety(self):
779
832
  """Test that module can be safely reloaded without side effects."""
@@ -231,13 +231,22 @@ class HTTPTransportTester:
231
231
  )
232
232
  all_results[scenario["name"]] = results
233
233
 
234
+ # Run specific get_ads filtering tests
235
+ print("\n🧪 Testing get_ads filtering functionality")
236
+ print("="*50)
237
+ ads_filter_results = self.test_get_ads_filtering()
238
+ all_results["get_ads_filtering"] = ads_filter_results
239
+
234
240
  # Summary
235
241
  print("\n🏁 TEST SUITE COMPLETED")
236
242
  print("="*30)
237
243
 
238
244
  all_passed = True
239
245
  for scenario_name, results in all_results.items():
240
- scenario_success = all(results.values())
246
+ if isinstance(results, dict):
247
+ scenario_success = all(results.values())
248
+ else:
249
+ scenario_success = results
241
250
  status = "✅ SUCCESS" if scenario_success else "❌ FAILED"
242
251
  print(f"{scenario_name}: {status}")
243
252
  if not scenario_success:
@@ -250,10 +259,72 @@ class HTTPTransportTester:
250
259
  print(" • MCP protocol compliance: Complete")
251
260
  print(" • Authentication integration: Working")
252
261
  print(" • All tools accessible via HTTP")
262
+ print(" • get_ads filtering: Working correctly")
253
263
  print(" • Ready for production use")
254
264
 
255
265
  return all_passed
256
266
 
267
+ def test_get_ads_filtering(self) -> Dict[str, bool]:
268
+ """Test get_ads function with different filtering parameters"""
269
+ results = {}
270
+
271
+ # Test with basic auth headers for these tests
272
+ auth_headers = {"Authorization": "Bearer test_pipeboard_token_12345"}
273
+
274
+ # Test 1: get_ads without filters (should use account endpoint)
275
+ print("🔍 Testing get_ads without filters")
276
+ result1 = self.test_tool_call("get_ads", {
277
+ "account_id": "act_123456789",
278
+ "limit": 5
279
+ }, auth_headers)
280
+ results["no_filters"] = result1["success"]
281
+ if result1["success"]:
282
+ print("✅ get_ads without filters successful")
283
+ else:
284
+ print(f"❌ get_ads without filters failed: {result1.get('text', 'Unknown error')}")
285
+
286
+ # Test 2: get_ads with campaign_id filter (should use campaign endpoint)
287
+ print("🔍 Testing get_ads with campaign_id filter")
288
+ result2 = self.test_tool_call("get_ads", {
289
+ "account_id": "act_123456789",
290
+ "campaign_id": "123456789012345",
291
+ "limit": 5
292
+ }, auth_headers)
293
+ results["campaign_filter"] = result2["success"]
294
+ if result2["success"]:
295
+ print("✅ get_ads with campaign_id filter successful")
296
+ else:
297
+ print(f"❌ get_ads with campaign_id filter failed: {result2.get('text', 'Unknown error')}")
298
+
299
+ # Test 3: get_ads with adset_id filter (should use adset endpoint)
300
+ print("🔍 Testing get_ads with adset_id filter")
301
+ result3 = self.test_tool_call("get_ads", {
302
+ "account_id": "act_123456789",
303
+ "adset_id": "120228975637820183",
304
+ "limit": 5
305
+ }, auth_headers)
306
+ results["adset_filter"] = result3["success"]
307
+ if result3["success"]:
308
+ print("✅ get_ads with adset_id filter successful")
309
+ else:
310
+ print(f"❌ get_ads with adset_id filter failed: {result3.get('text', 'Unknown error')}")
311
+
312
+ # Test 4: get_ads with both campaign_id and adset_id (adset_id should take priority)
313
+ print("🔍 Testing get_ads with both campaign_id and adset_id (adset_id priority)")
314
+ result4 = self.test_tool_call("get_ads", {
315
+ "account_id": "act_123456789",
316
+ "campaign_id": "123456789012345",
317
+ "adset_id": "120228975637820183",
318
+ "limit": 5
319
+ }, auth_headers)
320
+ results["priority_test"] = result4["success"]
321
+ if result4["success"]:
322
+ print("✅ get_ads priority test successful")
323
+ else:
324
+ print(f"❌ get_ads priority test failed: {result4.get('text', 'Unknown error')}")
325
+
326
+ return results
327
+
257
328
 
258
329
  def main():
259
330
  """Main test execution"""
@@ -1,136 +0,0 @@
1
- """Tests for the duplication module."""
2
-
3
- import os
4
- import json
5
- import pytest
6
- from unittest.mock import patch, AsyncMock
7
- from meta_ads_mcp.core.duplication import ENABLE_DUPLICATION
8
-
9
-
10
- def test_duplication_disabled_by_default():
11
- """Test that duplication is disabled by default."""
12
- # Test with no environment variable set
13
- with patch.dict(os.environ, {}, clear=True):
14
- from meta_ads_mcp.core import duplication
15
- # When imported fresh, it should be disabled
16
- assert not duplication.ENABLE_DUPLICATION
17
-
18
-
19
- def test_duplication_enabled_with_env_var():
20
- """Test that duplication is enabled when environment variable is set."""
21
- with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
22
- # Need to reload the module to pick up the new environment variable
23
- import importlib
24
- from meta_ads_mcp.core import duplication
25
- importlib.reload(duplication)
26
- assert duplication.ENABLE_DUPLICATION
27
-
28
-
29
- @pytest.mark.asyncio
30
- async def test_forward_duplication_request_no_token():
31
- """Test that _forward_duplication_request handles missing access token."""
32
- from meta_ads_mcp.core.duplication import _forward_duplication_request
33
-
34
- result = await _forward_duplication_request("campaign", "123456789", None, {})
35
- result_json = json.loads(result)
36
-
37
- assert result_json["error"] == "authentication_required"
38
- assert "access token not found" in result_json["message"]
39
-
40
-
41
- @pytest.mark.asyncio
42
- async def test_forward_duplication_request_with_token():
43
- """Test that _forward_duplication_request makes HTTP request with proper headers."""
44
- from meta_ads_mcp.core.duplication import _forward_duplication_request
45
- from unittest.mock import Mock
46
-
47
- mock_response = Mock()
48
- mock_response.status_code = 403
49
- mock_response.json.return_value = {"error": "premium_feature"}
50
-
51
- with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
52
- mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
53
-
54
- result = await _forward_duplication_request("campaign", "123456789", "test_token", {
55
- "name_suffix": " - Test"
56
- })
57
- result_json = json.loads(result)
58
-
59
- # Should return premium feature message for 403 response
60
- assert result_json["error"] == "premium_feature_required"
61
- assert "premium feature" in result_json["message"]
62
-
63
- # Verify the HTTP request was made with correct parameters
64
- mock_client.return_value.__aenter__.return_value.post.assert_called_once()
65
- call_args = mock_client.return_value.__aenter__.return_value.post.call_args
66
-
67
- # Check URL
68
- assert call_args[0][0] == "https://mcp.pipeboard.co/api/meta/duplicate/campaign/123456789"
69
-
70
- # Check headers
71
- headers = call_args[1]["headers"]
72
- assert headers["Authorization"] == "Bearer test_token"
73
- assert headers["Content-Type"] == "application/json"
74
-
75
- # Check JSON payload
76
- json_payload = call_args[1]["json"]
77
- assert json_payload == {"name_suffix": " - Test"}
78
-
79
-
80
- @pytest.mark.asyncio
81
- async def test_duplicate_campaign_function_available_when_enabled():
82
- """Test that duplicate_campaign function is available when feature is enabled."""
83
- with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
84
- # Reload module to pick up environment variable
85
- import importlib
86
- from meta_ads_mcp.core import duplication
87
- importlib.reload(duplication)
88
-
89
- # Function should be available
90
- assert hasattr(duplication, 'duplicate_campaign')
91
-
92
- # Test that it calls the forwarding function
93
- with patch("meta_ads_mcp.core.duplication._forward_duplication_request") as mock_forward:
94
- mock_forward.return_value = '{"success": true}'
95
-
96
- result = await duplication.duplicate_campaign("123456789", access_token="test_token")
97
-
98
- mock_forward.assert_called_once_with(
99
- "campaign",
100
- "123456789",
101
- "test_token",
102
- {
103
- "name_suffix": " - Copy",
104
- "include_ad_sets": True,
105
- "include_ads": True,
106
- "include_creatives": True,
107
- "copy_schedule": False,
108
- "new_daily_budget": None,
109
- "new_status": "PAUSED"
110
- }
111
- )
112
-
113
-
114
- def test_get_estimated_components():
115
- """Test the _get_estimated_components helper function."""
116
- from meta_ads_mcp.core.duplication import _get_estimated_components
117
-
118
- # Test campaign with all components
119
- campaign_result = _get_estimated_components("campaign", {
120
- "include_ad_sets": True,
121
- "include_ads": True,
122
- "include_creatives": True
123
- })
124
- assert campaign_result["campaigns"] == 1
125
- assert "ad_sets" in campaign_result
126
- assert "ads" in campaign_result
127
- assert "creatives" in campaign_result
128
-
129
- # Test adset
130
- adset_result = _get_estimated_components("adset", {"include_ads": True})
131
- assert adset_result["ad_sets"] == 1
132
- assert "ads" in adset_result
133
-
134
- # Test creative only
135
- creative_result = _get_estimated_components("creative", {})
136
- assert creative_result == {"creatives": 1}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes